Stabilize auth flow and profile images

This commit is contained in:
Codex
2026-07-20 13:38:39 +09:00
parent 57caca8dc8
commit 5d3eee7a16
128 changed files with 28860 additions and 1468 deletions
+7
View File
@@ -33,3 +33,10 @@ Thumbs.db
.env
.env.*
!.env.example
# Local Android/ADB state
.android-adb/
.tools/
# Local Docker Flutter caches
.docker-cache/
+10 -2
View File
@@ -63,5 +63,13 @@ Docker로 Flutter를 실행하는 경우:
## 주요 문서
- [신규 앱 개발 판단 문서](docs/tdc114plus-development-decision-brief-2026-07-01.md)
- [개발환경 구성 작업순서](docs/tdc114plus-development-environment-setup-plan-2026-07-02.md)
- [신규 앱 개발 판단 문서](docs/guide_tdc114plus_development_decision_brief_2026-07-01.md)
- [개발환경 구성 작업순서](docs/dev_env_tdc114plus_setup_plan_2026-07-02.md)
- [TDC114PLUS auth broker redirect flow](docs/00_guide_tdc114plus_auth_broker_redirect_flow_2026-07-15.md)
- [tdc114plus-auth 저장소 운영 정책](docs/00_policy_tdc114plus_auth_repo_2026-07-15.md)
- [tdc114plus-auth 운영/커밋 정책](docs/00_policy_tdc114plus_auth_operations_2026-07-15.md)
## 관련 저장소
- `tdc114plus` 앱 저장소: `https://gitea.hmac.kr/kevin/tdc114plus.git`
- `tdc114plus-auth` 공식 중계서버 저장소: `https://gitea.hmac.kr/kevin/tdc114plus-auth.git`
+2 -1
View File
@@ -2,7 +2,8 @@
<application
android:label="tdc114plus"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
+81
View File
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:tdc114plus/main.dart' as app;
const _assumeLoggedIn = bool.fromEnvironment('TDC114_SMOKE_ASSUME_LOGGED_IN');
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('shows Baron SSO login screen', (tester) async {
app.main();
await tester.pumpAndSettle();
if (_assumeLoggedIn) {
return;
}
expect(find.text('Baron SSO 로그인'), findsOneWidget);
expect(find.byIcon(Icons.open_in_browser), findsOneWidget);
expect(find.text('Baron SSO로 로그인'), findsOneWidget);
});
testWidgets('does not collect phone number inside the app', (tester) async {
if (_assumeLoggedIn) {
return;
}
app.main();
await tester.pumpAndSettle();
expect(find.byType(TextField), findsNothing);
expect(find.textContaining('Hosted Login'), findsOneWidget);
});
testWidgets('manual hosted login starts from the SSO button', (
tester,
) async {
if (_assumeLoggedIn) {
return;
}
app.main();
await tester.pumpAndSettle();
expect(find.text('Baron SSO로 로그인'), findsOneWidget);
});
testWidgets('checks post-login directory actions when session is preseeded', (
tester,
) async {
if (!_assumeLoggedIn) {
return;
}
app.main();
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(find.textContaining('검색 결과'), findsOneWidget);
final addFavorite = find.byTooltip('즐겨찾기 추가');
if (addFavorite.evaluate().isNotEmpty) {
await tester.tap(addFavorite.first);
await tester.pumpAndSettle();
await tester.tap(find.text('즐겨찾기').first);
await tester.pumpAndSettle();
expect(find.textContaining('검색 결과'), findsOneWidget);
}
final employeeTile = find.byType(ListTile);
expect(employeeTile, findsWidgets);
await tester.tap(employeeTile.first);
await tester.pumpAndSettle();
expect(find.text('전화'), findsOneWidget);
expect(find.text('문자'), findsOneWidget);
});
}
+133 -2
View File
@@ -1,9 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'src/features/auth/data/auth_session_store.dart';
import 'src/features/auth/domain/auth_models.dart';
import 'src/app.dart';
import 'src/smoke/smoke_overrides.dart';
void main() {
const _preauthToken = String.fromEnvironment('TDC114_PREAUTH_TOKEN');
const _preauthExpiresAt = String.fromEnvironment('TDC114_PREAUTH_EXPIRES_AT');
const _preauthUserId = String.fromEnvironment('TDC114_PREAUTH_USER_ID');
const _preauthUserName = String.fromEnvironment('TDC114_PREAUTH_USER_NAME');
const _preauthUserPhone = String.fromEnvironment('TDC114_PREAUTH_USER_PHONE');
const _preauthTenantId = String.fromEnvironment('TDC114_PREAUTH_TENANT_ID');
const _preauthTenantName = String.fromEnvironment('TDC114_PREAUTH_TENANT_NAME');
const _preauthTenantSlug = String.fromEnvironment('TDC114_PREAUTH_TENANT_SLUG');
const _preauthDepartment = String.fromEnvironment('TDC114_PREAUTH_DEPARTMENT');
const _preauthGrade = String.fromEnvironment('TDC114_PREAUTH_GRADE');
const _preauthPosition = String.fromEnvironment('TDC114_PREAUTH_POSITION');
const _preauthJobTitle = String.fromEnvironment('TDC114_PREAUTH_JOB_TITLE');
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ProviderScope(child: Tdc114PlusApp()));
runApp(const _BootstrapApp());
}
class _BootstrapApp extends StatefulWidget {
const _BootstrapApp();
@override
State<_BootstrapApp> createState() => _BootstrapAppState();
}
class _BootstrapAppState extends State<_BootstrapApp> {
late final Future<void> _bootstrapFuture;
@override
void initState() {
super.initState();
_bootstrapFuture = _seedSmokeSessionIfConfigured();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: _bootstrapFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: SafeArea(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('앱을 준비하고 있습니다...'),
],
),
),
),
),
);
}
if (snapshot.hasError) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('앱 시작 중 문제가 발생했습니다.'),
const SizedBox(height: 12),
Text('${snapshot.error}', textAlign: TextAlign.center),
],
),
),
),
),
),
);
}
return ProviderScope(
overrides: useSmokeMockDirectory
? [smokeDirectoryOverride, smokeOrganizationOverride]
: const [],
child: const Tdc114PlusApp(),
);
},
);
}
}
Future<void> _seedSmokeSessionIfConfigured() async {
if (_preauthToken.isEmpty ||
_preauthExpiresAt.isEmpty ||
_preauthUserId.isEmpty ||
_preauthUserName.isEmpty ||
_preauthUserPhone.isEmpty ||
_preauthTenantId.isEmpty ||
_preauthTenantName.isEmpty ||
_preauthTenantSlug.isEmpty) {
return;
}
final expiresAt = DateTime.tryParse(_preauthExpiresAt);
if (expiresAt == null) {
return;
}
await const AuthSessionStore().save(
PhoneLoginResponse(
status: 'ok',
token: _preauthToken,
expiresAt: expiresAt,
user: LoginUser(
id: _preauthUserId,
name: _preauthUserName,
phoneNumber: _preauthUserPhone,
tenantId: _preauthTenantId,
tenantName: _preauthTenantName,
tenantSlug: _preauthTenantSlug,
department: _valueOrNull(_preauthDepartment),
grade: _valueOrNull(_preauthGrade),
position: _valueOrNull(_preauthPosition),
jobTitle: _valueOrNull(_preauthJobTitle),
),
),
);
}
String? _valueOrNull(String value) => value.trim().isEmpty ? null : value;
+1 -1
View File
@@ -14,7 +14,7 @@ class Tdc114PlusApp extends StatelessWidget {
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF006D77)),
useMaterial3: true,
),
routerConfig: appRouter,
routerConfig: createAppRouter(),
);
}
}
@@ -0,0 +1,60 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
abstract class ContactLauncher {
const ContactLauncher();
Future<bool> call(String phoneNumber);
Future<bool> sms(String phoneNumber);
}
class UrlLauncherContactLauncher implements ContactLauncher {
const UrlLauncherContactLauncher();
@override
Future<bool> call(String phoneNumber) async {
final uri = buildCallUri(phoneNumber);
if (uri == null) {
return false;
}
return launchUrl(uri);
}
@override
Future<bool> sms(String phoneNumber) async {
final uri = buildSmsUri(phoneNumber);
if (uri == null) {
return false;
}
return launchUrl(uri);
}
}
Uri? buildCallUri(String phoneNumber) {
final normalized = _normalizePhoneNumber(phoneNumber);
if (normalized == null) {
return null;
}
return Uri(scheme: 'tel', path: normalized);
}
Uri? buildSmsUri(String phoneNumber) {
final normalized = _normalizePhoneNumber(phoneNumber);
if (normalized == null) {
return null;
}
return Uri(scheme: 'sms', path: normalized);
}
String? _normalizePhoneNumber(String value) {
final normalized = value.replaceAll(RegExp(r'[^0-9+]'), '');
if (normalized.isEmpty) {
return null;
}
return normalized;
}
final contactLauncherProvider = Provider<ContactLauncher>((ref) {
return const UrlLauncherContactLauncher();
});
+83 -8
View File
@@ -1,22 +1,97 @@
class AppEnvironment {
const AppEnvironment({
required this.ssoBaseUrl,
required this.orgFrontBaseUrl,
required this.apiBaseUrl,
required this.authApiBaseUrl,
required this.directoryApiBaseUrl,
required this.organizationApiBaseUrl,
required this.orgContextApiBaseUrl,
required this.orgContextTenantSlug,
required this.baronKeyId,
required this.baronKeySecret,
required this.appVersion,
required this.buildTimestamp,
});
factory AppEnvironment.fromDartDefine() {
return const AppEnvironment(
ssoBaseUrl: String.fromEnvironment(
const fallbackBaseUrl = 'https://sso.example.invalid';
const ssoBaseUrl = String.fromEnvironment(
'SSO_BASE_URL',
defaultValue: 'https://sso.example.invalid',
defaultValue: 'https://sso.hmac.kr',
);
const sharedApiBaseUrl = String.fromEnvironment(
'TDC114_API_BASE',
defaultValue: fallbackBaseUrl,
);
const authApiBaseOverride = String.fromEnvironment(
'TDC114_AUTH_API_BASE',
defaultValue: '',
);
const directoryApiBaseOverride = String.fromEnvironment(
'TDC114_DIRECTORY_API_BASE',
defaultValue: '',
);
const organizationApiBaseOverride = String.fromEnvironment(
'TDC114_ORGANIZATION_API_BASE',
defaultValue: '',
);
const orgContextApiBaseOverride = String.fromEnvironment(
'TDC114_ORG_CONTEXT_API_BASE',
defaultValue: '',
);
const orgContextTenantSlug = String.fromEnvironment(
'TDC114_ORG_CONTEXT_TENANT_SLUG',
defaultValue: 'hanmac-family',
);
const baronKeyId = String.fromEnvironment(
'TDC114_BARON_KEY_ID',
defaultValue: '',
);
const baronKeySecret = String.fromEnvironment(
'TDC114_BARON_KEY_SECRET',
defaultValue: '',
);
return AppEnvironment(
ssoBaseUrl: ssoBaseUrl,
apiBaseUrl: sharedApiBaseUrl,
authApiBaseUrl: authApiBaseOverride.isEmpty
? sharedApiBaseUrl
: authApiBaseOverride,
directoryApiBaseUrl: directoryApiBaseOverride.isEmpty
? sharedApiBaseUrl
: directoryApiBaseOverride,
organizationApiBaseUrl: organizationApiBaseOverride.isEmpty
? sharedApiBaseUrl
: organizationApiBaseOverride,
orgContextApiBaseUrl: orgContextApiBaseOverride.isEmpty
? (organizationApiBaseOverride.isEmpty
? sharedApiBaseUrl
: organizationApiBaseOverride)
: orgContextApiBaseOverride,
orgContextTenantSlug: orgContextTenantSlug,
baronKeyId: baronKeyId,
baronKeySecret: baronKeySecret,
appVersion: const String.fromEnvironment(
'APP_VERSION',
defaultValue: '0.1.0',
),
orgFrontBaseUrl: String.fromEnvironment(
'ORGFRONT_BASE_URL',
defaultValue: 'https://orgfront.example.invalid',
buildTimestamp: const String.fromEnvironment(
'TDC114_BUILD_TIME',
defaultValue: '',
),
);
}
final String ssoBaseUrl;
final String orgFrontBaseUrl;
final String apiBaseUrl;
final String authApiBaseUrl;
final String directoryApiBaseUrl;
final String organizationApiBaseUrl;
final String orgContextApiBaseUrl;
final String orgContextTenantSlug;
final String baronKeyId;
final String baronKeySecret;
final String appVersion;
final String buildTimestamp;
}
@@ -0,0 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app_environment.dart';
final appEnvironmentProvider = Provider<AppEnvironment>((ref) {
return AppEnvironment.fromDartDefine();
});
+15
View File
@@ -21,3 +21,18 @@ class ApiError {
return {'error': error, 'code': code, 'details': details};
}
}
class ApiException implements Exception {
const ApiException({required this.statusCode, required this.apiError});
final int statusCode;
final ApiError apiError;
String get code => apiError.code;
String get message => apiError.error;
@override
String toString() {
return 'ApiException($statusCode, $code, $message)';
}
}
@@ -0,0 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
final httpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
+9 -2
View File
@@ -1,11 +1,17 @@
import 'package:go_router/go_router.dart';
import '../../features/auth/presentation/auth_gate_screen.dart';
import '../../features/auth/presentation/login_screen.dart';
import '../../features/directory/presentation/directory_screen.dart';
final appRouter = GoRouter(
initialLocation: LoginScreen.routePath,
GoRouter createAppRouter() {
return GoRouter(
initialLocation: AuthGateScreen.routePath,
routes: [
GoRoute(
path: AuthGateScreen.routePath,
builder: (context, state) => const AuthGateScreen(),
),
GoRoute(
path: LoginScreen.routePath,
builder: (context, state) => const LoginScreen(),
@@ -16,3 +22,4 @@ final appRouter = GoRouter(
),
],
);
}
@@ -0,0 +1,104 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../core/network/api_error.dart';
import '../domain/auth_models.dart';
class AuthApiClient {
const AuthApiClient({
required this.httpClient,
required this.baseUri,
this.timeout = const Duration(seconds: 10),
});
final http.Client httpClient;
final Uri baseUri;
final Duration timeout;
Future<PhoneLoginResponse> phoneLogin(PhoneLoginRequest request) async {
final response = await httpClient
.post(
_resolve('/api/v1/tdc114plus/auth/phone-login'),
headers: const {
'accept': 'application/json',
'content-type': 'application/json',
},
body: jsonEncode(request.toJson()),
)
.timeout(timeout);
final decoded = _decodeObject(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
statusCode: response.statusCode,
apiError: ApiError.fromJson(decoded),
);
}
return PhoneLoginResponse.fromJson(decoded);
}
Future<PhoneLoginLinkInitResponse> requestPhoneLoginLink(
PhoneLoginLinkInitRequest request,
) async {
final response = await httpClient
.post(
_resolve('/api/v1/auth/link/init'),
headers: const {
'accept': 'application/json',
'content-type': 'application/json',
},
body: jsonEncode(request.toJson()),
)
.timeout(timeout);
final decoded = _decodeObject(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
statusCode: response.statusCode,
apiError: ApiError.fromJson(decoded),
);
}
return PhoneLoginLinkInitResponse.fromJson(decoded);
}
Future<PhoneLoginLinkPollResponse> pollPhoneLoginLink(
PhoneLoginLinkPollRequest request,
) async {
final response = await httpClient
.post(
_resolve('/api/v1/auth/link/poll'),
headers: const {
'accept': 'application/json',
'content-type': 'application/json',
},
body: jsonEncode(request.toJson()),
)
.timeout(timeout);
final decoded = _decodeObject(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
statusCode: response.statusCode,
apiError: ApiError.fromJson(decoded),
);
}
return PhoneLoginLinkPollResponse.fromJson(decoded);
}
Uri _resolve(String path) {
final normalizedBase = baseUri.path.endsWith('/')
? baseUri
: baseUri.replace(path: '${baseUri.path}/');
return normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
}
Map<String, dynamic> _decodeObject(String body) {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
return decoded;
}
throw const FormatException('Expected JSON object response');
}
}
@@ -0,0 +1,196 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/config/app_environment_provider.dart';
import '../../../core/network/api_error.dart';
import '../../../core/network/http_client_provider.dart';
import '../domain/auth_models.dart';
import 'auth_api_client.dart';
import 'auth_session_store.dart';
abstract class AuthRepository {
const AuthRepository();
Future<PhoneLoginLinkInitResponse> requestPhoneLoginLink(String phoneNumber);
Future<PhoneLoginLinkPollResponse> pollPhoneLoginLink(String pendingRef);
Future<PhoneLoginLinkInitResponse?> loadPendingPhoneLoginLink();
Future<void> clearPendingPhoneLoginLink();
Future<StoredAuthSession?> loadSession();
Future<void> logout();
}
abstract class LegacyPhoneLoginRepository {
const LegacyPhoneLoginRepository();
Future<PhoneLoginResponse> phoneLogin(String phoneNumber);
}
class RemoteAuthRepository
implements AuthRepository, LegacyPhoneLoginRepository {
const RemoteAuthRepository({
required this.apiClient,
required this.sessionStore,
required this.appVersion,
});
final AuthApiClient apiClient;
final AuthSessionStore sessionStore;
final String appVersion;
@override
Future<PhoneLoginResponse> phoneLogin(String phoneNumber) async {
final normalizedPhone = _normalizePhoneNumber(phoneNumber);
if (normalizedPhone.length < 9) {
throw _invalidPhoneNumber();
}
final response = await apiClient.phoneLogin(
PhoneLoginRequest(
phoneNumber: normalizedPhone,
device: LoginDeviceInfo(
platform: _platformName(),
appVersion: appVersion,
deviceName: _deviceName(),
),
),
);
await sessionStore.save(response);
return response;
}
@override
Future<PhoneLoginLinkInitResponse> requestPhoneLoginLink(
String phoneNumber,
) async {
final normalizedPhone = _normalizePhoneNumber(phoneNumber);
if (normalizedPhone.length < 9) {
throw _invalidPhoneNumber();
}
final response = await apiClient.requestPhoneLoginLink(
PhoneLoginLinkInitRequest(
phoneNumber: normalizedPhone,
device: LoginDeviceInfo(
platform: _platformName(),
appVersion: appVersion,
deviceName: _deviceName(),
),
),
);
await sessionStore.savePendingLink(response);
return response;
}
@override
Future<PhoneLoginLinkPollResponse> pollPhoneLoginLink(
String pendingRef,
) async {
final response = await apiClient.pollPhoneLoginLink(
PhoneLoginLinkPollRequest(pendingRef: pendingRef),
);
final session = response.session;
debugPrint(
'RemoteAuthRepository.pollPhoneLoginLink status=${response.status} hasSession=${session != null} token=${session?.token ?? ''} expiresAt=${session?.expiresAt.toUtc().toIso8601String() ?? ''}',
);
if (session != null) {
await sessionStore.save(session);
await sessionStore.clearPendingLink();
} else if (response.isExpired) {
await sessionStore.clearPendingLink();
}
return response;
}
@override
Future<PhoneLoginLinkInitResponse?> loadPendingPhoneLoginLink() {
return sessionStore.loadPendingLink();
}
@override
Future<void> clearPendingPhoneLoginLink() {
return sessionStore.clearPendingLink();
}
@override
Future<StoredAuthSession?> loadSession() => sessionStore.load();
@override
Future<void> logout() => sessionStore.clear();
String _normalizePhoneNumber(String value) {
return value.replaceAll(RegExp(r'[^0-9+]'), '');
}
ApiException _invalidPhoneNumber() {
return const ApiException(
statusCode: 400,
apiError: ApiError(
error: '전화번호 형식을 확인해 주세요.',
code: 'invalid_phone_number',
details: {},
),
);
}
String _platformName() {
if (kIsWeb) {
return 'web';
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return 'android';
case TargetPlatform.iOS:
return 'ios';
case TargetPlatform.macOS:
return 'macos';
case TargetPlatform.windows:
return 'windows';
case TargetPlatform.linux:
return 'linux';
case TargetPlatform.fuchsia:
return 'fuchsia';
}
}
String _deviceName() {
if (kIsWeb) {
return 'flutter-web';
}
return _platformName();
}
}
final authApiClientProvider = Provider<AuthApiClient>((ref) {
final environment = ref.watch(appEnvironmentProvider);
return AuthApiClient(
httpClient: ref.watch(httpClientProvider),
baseUri: Uri.parse(environment.authApiBaseUrl),
);
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final environment = ref.watch(appEnvironmentProvider);
return RemoteAuthRepository(
apiClient: ref.watch(authApiClientProvider),
sessionStore: ref.watch(authSessionStoreProvider),
appVersion: environment.appVersion,
);
});
final legacyPhoneLoginRepositoryProvider = Provider<LegacyPhoneLoginRepository>(
(ref) {
final environment = ref.watch(appEnvironmentProvider);
return RemoteAuthRepository(
apiClient: ref.watch(authApiClientProvider),
sessionStore: ref.watch(authSessionStoreProvider),
appVersion: environment.appVersion,
);
},
);
@@ -0,0 +1,178 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../domain/auth_models.dart';
class AuthSessionStore {
const AuthSessionStore();
static const _tokenKey = 'tdc114plus.auth.token';
static const _expiresAtKey = 'tdc114plus.auth.expiresAt';
static const _userKey = 'tdc114plus.auth.user';
static const _orgContextCredentialKey =
'tdc114plus.auth.orgContextCredential';
static const _pendingRefKey = 'tdc114plus.auth.pending.ref';
static const _pendingExpiresAtKey = 'tdc114plus.auth.pending.expiresAt';
static const _pendingResendAfterAtKey =
'tdc114plus.auth.pending.resendAfterAt';
static const _pendingPollIntervalKey =
'tdc114plus.auth.pending.pollInterval';
static const _pendingProviderKey = 'tdc114plus.auth.pending.provider';
Future<void> save(PhoneLoginResponse response) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, response.token);
await prefs.setString(
_expiresAtKey,
response.expiresAt.toUtc().toIso8601String(),
);
await prefs.setString(_userKey, jsonEncode(response.user.toJson()));
final orgContextCredential = response.orgContextCredential;
if (orgContextCredential == null) {
await prefs.remove(_orgContextCredentialKey);
} else {
await prefs.setString(
_orgContextCredentialKey,
jsonEncode(orgContextCredential.toJson()),
);
}
debugPrint(
'AuthSessionStore.save token=${response.token} tokenLength=${response.token.length} expiresAt=${response.expiresAt.toUtc().toIso8601String()} orgContextCredential=${orgContextCredential != null}',
);
}
Future<void> savePendingLink(PhoneLoginLinkInitResponse response) async {
final prefs = await SharedPreferences.getInstance();
final now = DateTime.now().toUtc();
await prefs.setString(_pendingRefKey, response.pendingRef);
await prefs.setString(
_pendingExpiresAtKey,
now.add(Duration(seconds: response.expiresIn)).toIso8601String(),
);
await prefs.setString(
_pendingResendAfterAtKey,
now.add(Duration(seconds: response.resendAfter)).toIso8601String(),
);
await prefs.setInt(_pendingPollIntervalKey, response.interval);
final provider = response.provider;
if (provider == null || provider.trim().isEmpty) {
await prefs.remove(_pendingProviderKey);
} else {
await prefs.setString(_pendingProviderKey, provider);
}
}
Future<PhoneLoginLinkInitResponse?> loadPendingLink() async {
final prefs = await SharedPreferences.getInstance();
final pendingRef = prefs.getString(_pendingRefKey);
final expiresAtValue = prefs.getString(_pendingExpiresAtKey);
if (pendingRef == null ||
pendingRef.trim().isEmpty ||
expiresAtValue == null) {
return null;
}
final now = DateTime.now().toUtc();
final expiresAt = DateTime.tryParse(expiresAtValue)?.toUtc();
if (expiresAt == null || !expiresAt.isAfter(now)) {
await clearPendingLink();
return null;
}
final resendAfterAt = DateTime.tryParse(
prefs.getString(_pendingResendAfterAtKey) ?? '',
)?.toUtc();
final expiresIn = expiresAt.difference(now).inSeconds;
final resendAfter = resendAfterAt == null || !resendAfterAt.isAfter(now)
? 0
: resendAfterAt.difference(now).inSeconds;
return PhoneLoginLinkInitResponse(
status: 'pending',
pendingRef: pendingRef,
expiresIn: expiresIn,
interval: prefs.getInt(_pendingPollIntervalKey) ?? 3,
resendAfter: resendAfter,
provider: prefs.getString(_pendingProviderKey),
);
}
Future<void> clearPendingLink() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingRefKey);
await prefs.remove(_pendingExpiresAtKey);
await prefs.remove(_pendingResendAfterAtKey);
await prefs.remove(_pendingPollIntervalKey);
await prefs.remove(_pendingProviderKey);
}
Future<StoredAuthSession?> load() async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_tokenKey);
final expiresAtValue = prefs.getString(_expiresAtKey);
final userValue = prefs.getString(_userKey);
final orgContextCredentialValue = prefs.getString(_orgContextCredentialKey);
if (token == null || expiresAtValue == null || userValue == null) {
debugPrint(
'AuthSessionStore.load missing token=${token != null} expiresAt=${expiresAtValue != null} user=${userValue != null}',
);
return null;
}
final session = StoredAuthSession(
token: token,
expiresAt: DateTime.parse(expiresAtValue),
user: LoginUser.fromJson(jsonDecode(userValue) as Map<String, dynamic>),
orgContextCredential: _decodeOrgContextCredential(
orgContextCredentialValue,
),
);
debugPrint(
'AuthSessionStore.load token=${session.token} tokenLength=${session.token.length} expiresAt=${session.expiresAt.toUtc().toIso8601String()} expired=${session.isExpired} orgContextCredential=${session.orgContextCredential != null}',
);
return session;
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_expiresAtKey);
await prefs.remove(_userKey);
await prefs.remove(_orgContextCredentialKey);
await clearPendingLink();
debugPrint('AuthSessionStore.clear');
}
OrgContextCredential? _decodeOrgContextCredential(String? value) {
if (value == null || value.trim().isEmpty) {
return null;
}
try {
return OrgContextCredential.fromJsonOrNull(jsonDecode(value));
} catch (_) {
return null;
}
}
}
class StoredAuthSession {
const StoredAuthSession({
required this.token,
required this.expiresAt,
required this.user,
this.orgContextCredential,
});
final String token;
final DateTime expiresAt;
final LoginUser user;
final OrgContextCredential? orgContextCredential;
bool get isExpired => !expiresAt.isAfter(DateTime.now().toUtc());
}
final authSessionStoreProvider = Provider<AuthSessionStore>((ref) {
return const AuthSessionStore();
});
@@ -101,19 +101,24 @@ class PhoneLoginResponse {
required this.token,
required this.expiresAt,
required this.user,
this.orgContextCredential,
});
final String status;
final String token;
final DateTime expiresAt;
final LoginUser user;
final OrgContextCredential? orgContextCredential;
factory PhoneLoginResponse.fromJson(Map<String, dynamic> json) {
return PhoneLoginResponse(
status: json['status'] as String? ?? '',
token: json['token'] as String? ?? '',
token: json['token'] as String? ?? json['accessToken'] as String? ?? '',
expiresAt: DateTime.parse(json['expiresAt'] as String),
user: LoginUser.fromJson(json['user'] as Map<String, dynamic>? ?? {}),
orgContextCredential: OrgContextCredential.fromJsonOrNull(
json['orgContextCredential'] ?? json['org_context'],
),
);
}
@@ -123,10 +128,176 @@ class PhoneLoginResponse {
'token': token,
'expiresAt': expiresAt.toUtc().toIso8601String(),
'user': user.toJson(),
if (orgContextCredential != null)
'orgContextCredential': orgContextCredential!.toJson(),
};
}
}
class OrgContextCredential {
const OrgContextCredential({
required this.baseUrl,
required this.tenantSlug,
required this.keyId,
required this.keySecret,
this.expiresAt,
});
final String baseUrl;
final String tenantSlug;
final String keyId;
final String keySecret;
final DateTime? expiresAt;
bool get isUsable {
return baseUrl.trim().isNotEmpty &&
tenantSlug.trim().isNotEmpty &&
keyId.trim().isNotEmpty &&
keySecret.trim().isNotEmpty &&
(expiresAt == null || expiresAt!.isAfter(DateTime.now().toUtc()));
}
factory OrgContextCredential.fromJson(Map<String, dynamic> json) {
return OrgContextCredential(
baseUrl: _readString(json, const ['baseUrl', 'base_url']),
tenantSlug: _readString(json, const ['tenantSlug', 'tenant_slug']),
keyId: _readString(json, const ['keyId', 'key_id']),
keySecret: _readString(json, const ['keySecret', 'key_secret']),
expiresAt: _readDate(json['expiresAt'] ?? json['expires_at']),
);
}
static OrgContextCredential? fromJsonOrNull(Object? value) {
if (value is! Map) {
return null;
}
final credential = OrgContextCredential.fromJson(
Map<String, dynamic>.from(value),
);
return credential.isUsable ? credential : null;
}
Map<String, dynamic> toJson() {
return {
'baseUrl': baseUrl,
'tenantSlug': tenantSlug,
'keyId': keyId,
'keySecret': keySecret,
if (expiresAt != null) 'expiresAt': expiresAt!.toUtc().toIso8601String(),
};
}
static String _readString(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final value = json[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
}
return '';
}
static DateTime? _readDate(Object? value) {
if (value is! String || value.trim().isEmpty) {
return null;
}
return DateTime.tryParse(value)?.toUtc();
}
}
class PhoneLoginLinkInitRequest {
const PhoneLoginLinkInitRequest({
required this.phoneNumber,
required this.device,
});
final String phoneNumber;
final LoginDeviceInfo device;
Map<String, dynamic> toJson() {
return {'phoneNumber': phoneNumber, 'device': device.toJson()};
}
}
class PhoneLoginLinkInitResponse {
const PhoneLoginLinkInitResponse({
required this.status,
required this.pendingRef,
required this.expiresIn,
required this.interval,
required this.resendAfter,
this.provider,
});
final String status;
final String pendingRef;
final int expiresIn;
final int interval;
final int resendAfter;
final String? provider;
factory PhoneLoginLinkInitResponse.fromJson(Map<String, dynamic> json) {
return PhoneLoginLinkInitResponse(
status: json['status'] as String? ?? '',
pendingRef: json['pendingRef'] as String? ?? '',
expiresIn: json['expiresIn'] as int? ?? 180,
interval: json['interval'] as int? ?? json['pollInterval'] as int? ?? 3,
resendAfter: json['resendAfter'] as int? ?? 30,
provider: json['provider'] as String?,
);
}
}
class PhoneLoginLinkPollRequest {
const PhoneLoginLinkPollRequest({required this.pendingRef});
final String pendingRef;
Map<String, dynamic> toJson() {
return {'pendingRef': pendingRef};
}
}
class PhoneLoginLinkPollResponse {
const PhoneLoginLinkPollResponse({
required this.status,
this.code,
this.interval,
this.session,
});
final String status;
final String? code;
final int? interval;
final PhoneLoginResponse? session;
bool get isPending =>
status == 'pending' ||
code == 'authorization_pending' ||
code == 'slow_down';
bool get isExpired => code == 'expired_token';
factory PhoneLoginLinkPollResponse.fromJson(Map<String, dynamic> json) {
PhoneLoginResponse? session;
if (json['session'] is Map<String, dynamic>) {
session = PhoneLoginResponse.fromJson(
json['session'] as Map<String, dynamic>,
);
} else if ((json['token'] != null || json['accessToken'] != null) &&
json['expiresAt'] != null) {
session = PhoneLoginResponse.fromJson(json);
}
return PhoneLoginLinkPollResponse(
status: json['status'] as String? ?? '',
code: json['code'] as String?,
interval: json['interval'] as int? ?? json['pollInterval'] as int?,
session: session,
);
}
}
class UserPermissions {
const UserPermissions({
required this.directory,
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../data/auth_repository.dart';
import '../data/auth_session_store.dart';
import 'login_screen.dart';
import '../../directory/presentation/directory_screen.dart';
class AuthGateScreen extends ConsumerStatefulWidget {
const AuthGateScreen({super.key});
static const routePath = '/';
@override
ConsumerState<AuthGateScreen> createState() => _AuthGateScreenState();
}
class _AuthGateScreenState extends ConsumerState<AuthGateScreen> {
var _resolved = false;
@override
Widget build(BuildContext context) {
return FutureBuilder<StoredAuthSession?>(
future: ref.read(authRepositoryProvider).loadSession(),
builder: (context, snapshot) {
if (!_resolved && snapshot.connectionState == ConnectionState.done) {
_resolved = true;
final router = GoRouter.of(context);
WidgetsBinding.instance.addPostFrameCallback((_) async {
final session = snapshot.data;
if (session == null || session.isExpired) {
if (session != null) {
await ref.read(authRepositoryProvider).logout();
}
if (mounted) {
router.go(LoginScreen.routePath);
}
return;
}
if (mounted) {
router.go(DirectoryScreen.routePath);
}
});
}
return const Scaffold(
body: SafeArea(child: Center(child: CircularProgressIndicator())),
);
},
);
}
}
@@ -1,72 +1,705 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import '../../../core/config/app_environment_provider.dart';
import '../../../core/network/api_error.dart';
import '../data/auth_repository.dart';
import '../domain/auth_models.dart';
import '../../directory/presentation/directory_screen.dart';
class LoginScreen extends StatefulWidget {
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
static const routePath = '/login';
@override
State<LoginScreen> createState() => _LoginScreenState();
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
class _LoginScreenState extends ConsumerState<LoginScreen>
with WidgetsBindingObserver {
final _phoneController = TextEditingController();
Timer? _pollTimer;
Timer? _countdownTimer;
var _isSubmitting = false;
var _pollInFlight = false;
String? _errorMessage;
String? _statusMessage;
PhoneLoginLinkInitResponse? _pendingLink;
var _secondsUntilExpiry = 0;
var _secondsUntilResend = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
unawaited(_restorePendingLink());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_pollTimer?.cancel();
_countdownTimer?.cancel();
_phoneController.dispose();
super.dispose();
}
void _submit() {
if (_phoneController.text.trim().isEmpty) {
bool get _hasPendingLink => _pendingLink != null;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed || !_hasPendingLink) {
return;
}
_ensurePolling();
unawaited(_pollPendingLink());
}
Future<void> _submit() async {
final phoneNumber = _phoneController.text.trim();
if (phoneNumber.isEmpty) {
setState(() {
_statusMessage = null;
_errorMessage = '전화번호를 입력해 주세요.';
});
return;
}
setState(() {
_isSubmitting = true;
_pendingLink = null;
_secondsUntilExpiry = 0;
_secondsUntilResend = 0;
_errorMessage = null;
_statusMessage = '로그인 링크를 요청하고 있습니다.';
});
try {
_pollTimer?.cancel();
_countdownTimer?.cancel();
await ref.read(authRepositoryProvider).clearPendingPhoneLoginLink();
final response = await ref
.read(authRepositoryProvider)
.requestPhoneLoginLink(phoneNumber);
if (!mounted) {
return;
}
_startPendingLinkFlow(response);
setState(() {
_statusMessage = '문자 링크를 누른 뒤 TDC114PLUS 앱으로 돌아오세요.';
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_statusMessage = null;
_errorMessage = '로그인 링크 요청 실패: ${_messageForError(error)}';
});
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
Future<void> _restorePendingLink() async {
final pendingLink = await ref
.read(authRepositoryProvider)
.loadPendingPhoneLoginLink();
if (!mounted || pendingLink == null) {
return;
}
setState(() {
_pendingLink = pendingLink;
_secondsUntilExpiry = pendingLink.expiresIn;
_secondsUntilResend = pendingLink.resendAfter;
_statusMessage = '이전 로그인 승인 상태를 확인하고 있습니다.';
_errorMessage = null;
});
_startPendingTimers(pendingLink);
unawaited(_pollPendingLink());
}
void _startPendingLinkFlow(PhoneLoginLinkInitResponse response) {
_pollTimer?.cancel();
_countdownTimer?.cancel();
setState(() {
_pendingLink = response;
_secondsUntilExpiry = response.expiresIn;
_secondsUntilResend = response.resendAfter;
});
_startPendingTimers(response);
}
void _startPendingTimers(PhoneLoginLinkInitResponse response) {
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) {
return;
}
final nextExpiry = _secondsUntilExpiry > 0 ? _secondsUntilExpiry - 1 : 0;
final nextResend = _secondsUntilResend > 0 ? _secondsUntilResend - 1 : 0;
setState(() {
_secondsUntilExpiry = nextExpiry;
_secondsUntilResend = nextResend;
});
if (nextExpiry == 0) {
_pollTimer?.cancel();
_countdownTimer?.cancel();
setState(() {
_pendingLink = null;
_statusMessage = null;
_errorMessage = '로그인 링크 유효시간이 지났습니다. 다시 요청해 주세요.';
});
unawaited(
ref.read(authRepositoryProvider).clearPendingPhoneLoginLink(),
);
}
});
_ensurePolling();
unawaited(_pollPendingLink());
}
void _ensurePolling() {
final pendingLink = _pendingLink;
if (pendingLink == null || _pollTimer?.isActive == true) {
return;
}
_pollTimer = Timer.periodic(
Duration(seconds: pendingLink.interval.clamp(1, 30)),
(_) => _pollPendingLink(),
);
}
Future<void> _pollPendingLink() async {
final pendingRef = _pendingLink?.pendingRef;
if (pendingRef == null || _pollInFlight) {
return;
}
_pollInFlight = true;
try {
final response = await ref
.read(authRepositoryProvider)
.pollPhoneLoginLink(pendingRef);
if (!mounted) {
return;
}
if (response.session != null) {
_pollTimer?.cancel();
_countdownTimer?.cancel();
setState(() {
_pendingLink = null;
_statusMessage = '로그인이 완료되었습니다. 앱 화면으로 이동합니다.';
_errorMessage = null;
});
if (mounted) {
context.go(DirectoryScreen.routePath);
}
return;
}
if (response.isExpired) {
_pollTimer?.cancel();
_countdownTimer?.cancel();
setState(() {
_pendingLink = null;
_statusMessage = null;
_errorMessage = '로그인 링크 유효시간이 지났습니다. 다시 요청해 주세요.';
});
unawaited(
ref.read(authRepositoryProvider).clearPendingPhoneLoginLink(),
);
return;
}
if (mounted) {
setState(() {
_errorMessage = null;
_statusMessage = '문자 링크를 누른 뒤 TDC114PLUS 앱으로 돌아오세요.';
});
}
final nextInterval = response.interval;
if (nextInterval != null &&
_pendingLink != null &&
nextInterval != _pendingLink!.interval) {
_pollTimer?.cancel();
_pollTimer = Timer.periodic(
Duration(seconds: nextInterval.clamp(1, 30)),
(_) => _pollPendingLink(),
);
setState(() {
_pendingLink = PhoneLoginLinkInitResponse(
status: _pendingLink!.status,
pendingRef: _pendingLink!.pendingRef,
expiresIn: _secondsUntilExpiry,
interval: nextInterval,
resendAfter: _secondsUntilResend,
provider: _pendingLink!.provider,
);
});
}
} catch (error) {
if (!mounted) {
return;
}
if (error is ApiException &&
(error.code == 'pending_ref_not_found' ||
error.code == 'pending_ref_expired')) {
_pollTimer?.cancel();
_countdownTimer?.cancel();
unawaited(
ref.read(authRepositoryProvider).clearPendingPhoneLoginLink(),
);
setState(() {
_pendingLink = null;
_secondsUntilExpiry = 0;
_secondsUntilResend = 0;
_statusMessage = null;
_errorMessage = '이전 로그인 요청이 만료되었습니다. 다시 보내기를 눌러 주세요.';
});
return;
}
setState(() {
_errorMessage = '승인 상태 확인 실패: ${_messageForError(error)}';
});
} finally {
_pollInFlight = false;
}
}
void _resetPendingState() {
_pollTimer?.cancel();
_countdownTimer?.cancel();
unawaited(ref.read(authRepositoryProvider).clearPendingPhoneLoginLink());
setState(() {
_pendingLink = null;
_secondsUntilExpiry = 0;
_secondsUntilResend = 0;
_statusMessage = null;
_errorMessage = null;
});
}
String _messageForError(Object error) {
if (error is ApiException) {
final message = error.apiError.error.trim();
if (message.isNotEmpty) {
return message;
}
if (error.code.isNotEmpty) {
return error.code;
}
return '서버 응답 오류(${error.statusCode})';
}
if (error is TimeoutException) {
return '서버 응답 시간이 초과되었습니다.';
}
if (error is SocketException || error is http.ClientException) {
return '인증 서버에 연결하지 못했습니다.';
}
if (error is Exception &&
error.toString().contains('invalid_phone_number')) {
return '전화번호 형식을 확인해 주세요.';
}
return '로그인 링크를 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.';
}
@override
Widget build(BuildContext context) {
final environment = ref.watch(appEnvironmentProvider);
final buildTimestamp = environment.buildTimestamp.trim();
final canResend = !_isSubmitting && _secondsUntilResend == 0;
final keyboardInset = MediaQuery.viewInsetsOf(context).bottom;
final keyboardVisible = keyboardInset > 0;
const background = Color(0xFF030616);
const surface = Color(0xFF10182A);
const surfaceBorder = Color(0xFF1D2A44);
const primary = Color(0xFFA8DEFF);
const onDark = Color(0xFFE8F2FF);
const muted = Color(0xFFA8B4C7);
return Scaffold(
appBar: AppBar(title: const Text('tdc114plus')),
backgroundColor: background,
appBar: AppBar(
backgroundColor: const Color(0xFF0E1729),
foregroundColor: onDark,
title: const Text('Baron SW 포털'),
actions: const [
Padding(
padding: EdgeInsets.only(right: 16),
child: Icon(Icons.dark_mode_outlined),
),
],
),
bottomNavigationBar: keyboardVisible
? AnimatedPadding(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: keyboardInset),
child: SafeArea(
top: false,
child: Container(
color: background,
padding: const EdgeInsets.fromLTRB(24, 10, 24, 12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: _buildLoginLinkButton(canResend: canResend),
),
),
),
)
: null,
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(24),
child: Center(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(24, 24, 24, keyboardVisible ? 96 : 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: DecoratedBox(
decoration: BoxDecoration(
color: surface,
border: Border.all(color: surfaceBorder),
borderRadius: BorderRadius.circular(24),
boxShadow: const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 24,
offset: Offset(0, 16),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
Text(
'Baron SSO 로그인',
style: Theme.of(context).textTheme.headlineSmall,
const Text(
'TDC114PLUS',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
const SizedBox(height: 6),
const Text(
'Baron SSO Login',
textAlign: TextAlign.center,
style: TextStyle(
color: muted,
fontSize: 13,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 8),
Text(
'Baron SSO에 등록된 전화번호를 입력하세요.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
TextField(
const CircleAvatar(
radius: 42,
backgroundColor: Color(0xFF26334C),
child: Icon(Icons.person, color: primary, size: 44),
),
const SizedBox(height: 24),
Text(
'로그인 링크 발송',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
color: primary,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
const Text(
'문자 승인 후 앱으로 돌아오면 자동 로그인됩니다.',
textAlign: TextAlign.center,
style: TextStyle(color: muted, height: 1.5),
),
if (buildTimestamp.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'빌드 시각: $buildTimestamp',
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0x99FFFFFF),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 24),
DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFF0A5D43),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFF2FA977)),
boxShadow: const [
BoxShadow(
color: Color(0x553CD694),
blurRadius: 22,
offset: Offset(0, 10),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(
Icons.phone_android,
color: Colors.white,
size: 44,
),
const SizedBox(width: 14),
Expanded(
child: TextField(
controller: _phoneController,
enabled:
!_hasPendingLink && !_isSubmitting,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.done,
inputFormatters: const [
_PhoneNumberTextInputFormatter(),
],
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: 0,
),
cursorColor: Colors.white,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: '전화번호',
prefixIcon: Icon(Icons.phone_android),
border: InputBorder.none,
isDense: true,
counterText: '',
hintText: '010-0000-0000',
hintStyle: TextStyle(
color: Color(0x66FFFFFF),
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: 0,
),
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _submit,
icon: const Icon(Icons.login),
label: const Text('로그인'),
),
),
],
),
const SizedBox(height: 8),
const Divider(
height: 1,
thickness: 3,
indent: 72,
endIndent: 12,
color: Color(0x553CD694),
),
const SizedBox(height: 10),
const Text(
'문자 수신 가능한 전화번호를 입력하세요.',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xCCFFFFFF),
fontSize: 13,
),
),
],
),
),
),
if (_statusMessage != null) ...[
const SizedBox(height: 18),
Text(
_statusMessage!,
textAlign: TextAlign.center,
style: const TextStyle(color: primary, height: 1.5),
),
],
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFFFF9CA3),
height: 1.5,
),
),
],
if (_hasPendingLink) ...[
const SizedBox(height: 20),
_PendingLinkStatus(
expiresIn: _secondsUntilExpiry,
resendAfter: _secondsUntilResend,
provider: _pendingLink?.provider,
),
],
if (!keyboardVisible) ...[
const SizedBox(height: 24),
_buildLoginLinkButton(canResend: canResend),
if (_hasPendingLink) ...[
const SizedBox(height: 10),
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: onDark,
side: const BorderSide(color: Color(0xFF34425C)),
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: _resetPendingState,
child: const Text('다른 번호 입력'),
),
],
],
],
),
),
),
),
),
),
),
);
}
Widget _buildLoginLinkButton({required bool canResend}) {
const primary = Color(0xFFA8DEFF);
return FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: primary,
foregroundColor: const Color(0xFF07101F),
disabledBackgroundColor: const Color(0xFF314158),
disabledForegroundColor: const Color(0xFF91A0B6),
minimumSize: const Size.fromHeight(54),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: _isSubmitting || (_hasPendingLink && !canResend)
? null
: _submit,
icon: _isSubmitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(_hasPendingLink ? Icons.refresh : Icons.link),
label: Text(
_isSubmitting
? '로그인 링크 요청 중'
: _hasPendingLink
? '로그인 링크 다시 보내기'
: '로그인 링크 보내기',
),
);
}
}
class _PhoneNumberTextInputFormatter extends TextInputFormatter {
const _PhoneNumberTextInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'[^0-9]'), '');
final limited = digits.length > 11 ? digits.substring(0, 11) : digits;
final formatted = _formatPhoneDigits(limited);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
String _formatPhoneDigits(String digits) {
if (digits.length <= 3) {
return digits;
}
if (digits.length <= 7) {
return '${digits.substring(0, 3)}-${digits.substring(3)}';
}
return '${digits.substring(0, 3)}-${digits.substring(3, 7)}-${digits.substring(7)}';
}
}
class _PendingLinkStatus extends StatelessWidget {
const _PendingLinkStatus({
required this.expiresIn,
required this.resendAfter,
required this.provider,
});
final int expiresIn;
final int resendAfter;
final String? provider;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF07101F),
border: Border.all(color: const Color(0xFF263653)),
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'승인 대기 중',
style: TextStyle(
color: Color(0xFFA8DEFF),
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text(
'문자 링크를 승인한 뒤 이 앱으로 돌아오면 자동으로 로그인됩니다.',
style: TextStyle(color: Color(0xFFE8F2FF), height: 1.45),
),
const SizedBox(height: 8),
Text(
'남은 유효시간: ${_formatDuration(expiresIn)}',
style: const TextStyle(color: Color(0xFFA8B4C7)),
),
Text(
'재전송 가능까지: ${_formatDuration(resendAfter)}',
style: const TextStyle(color: Color(0xFFA8B4C7)),
),
if (provider != null && provider!.trim().isNotEmpty)
Text(
'인증 공급자: $provider',
style: const TextStyle(color: Color(0xFFA8B4C7)),
),
],
),
);
}
static String _formatDuration(int seconds) {
final safeSeconds = seconds < 0 ? 0 : seconds;
final minutes = safeSeconds ~/ 60;
final remainingSeconds = safeSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,115 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../../core/config/app_environment_provider.dart';
import '../../../core/network/api_error.dart';
import '../../../core/network/http_client_provider.dart';
import '../../auth/data/auth_session_store.dart';
import '../domain/employee.dart';
class DirectoryApiClient {
const DirectoryApiClient({
required this.httpClient,
required this.baseUri,
required this.sessionStore,
this.timeout = const Duration(seconds: 10),
});
final http.Client httpClient;
final Uri baseUri;
final AuthSessionStore sessionStore;
final Duration timeout;
Future<EmployeeListResponse> listEmployees({
String? query,
String? tenantId,
String? tenantSlug,
String? department,
int limit = 50,
int offset = 0,
String? cursor,
}) async {
final response = await httpClient
.get(
_resolve(
'/api/v1/tdc114plus/directory/employees',
queryParameters: {
'q': query,
'tenantId': tenantId,
'tenantSlug': tenantSlug,
'department': department,
'limit': '$limit',
'offset': '$offset',
'cursor': cursor,
},
),
headers: await _headers(),
)
.timeout(timeout);
return EmployeeListResponse.fromJson(_decodeOk(response));
}
Future<EmployeeDetail> getEmployee(String employeeId) async {
final response = await httpClient
.get(
_resolve('/api/v1/tdc114plus/directory/employees/$employeeId'),
headers: await _headers(),
)
.timeout(timeout);
return EmployeeDetail.fromJson(_decodeOk(response));
}
Future<Map<String, String>> _headers() async {
final session = await sessionStore.load();
return {
'accept': 'application/json',
if (session?.token != null) 'authorization': 'Bearer ${session!.token}',
};
}
Map<String, dynamic> _decodeOk(http.Response response) {
final decoded = _decodeObject(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
statusCode: response.statusCode,
apiError: ApiError.fromJson(decoded),
);
}
return decoded;
}
Uri _resolve(String path, {Map<String, String?> queryParameters = const {}}) {
final normalizedBase = baseUri.path.endsWith('/')
? baseUri
: baseUri.replace(path: '${baseUri.path}/');
final uri = normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
final filteredQuery = Map<String, String>.fromEntries(
queryParameters.entries
.where((entry) {
return entry.value != null && entry.value!.trim().isNotEmpty;
})
.map((entry) => MapEntry(entry.key, entry.value!)),
);
return uri.replace(queryParameters: filteredQuery);
}
Map<String, dynamic> _decodeObject(String body) {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
return decoded;
}
throw const FormatException('Expected JSON object response');
}
}
final directoryApiClientProvider = Provider<DirectoryApiClient>((ref) {
final environment = ref.watch(appEnvironmentProvider);
return DirectoryApiClient(
httpClient: ref.watch(httpClientProvider),
baseUri: Uri.parse(environment.directoryApiBaseUrl),
sessionStore: ref.watch(authSessionStoreProvider),
);
});
@@ -0,0 +1,227 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../organization/data/org_context_api_client.dart';
import 'directory_api_client.dart';
import '../domain/employee.dart';
class DirectoryQuery {
const DirectoryQuery({
required this.query,
required this.tenantSlug,
this.department,
this.tenantSlugs = const <String>[],
});
final String query;
final String tenantSlug;
final String? department;
final List<String> tenantSlugs;
String? get apiQuery => query.trim().isEmpty ? null : query.trim();
String? get apiTenantSlug => tenantSlug == 'all' ? null : tenantSlug;
List<String>? get apiTenantSlugs {
if (tenantSlugs.isNotEmpty) {
return tenantSlugs;
}
final slug = apiTenantSlug;
return slug == null ? null : [slug];
}
String? get apiDepartment {
if (department == null) {
return null;
}
final normalized = department!.trim();
return normalized.isEmpty ? null : normalized;
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is DirectoryQuery &&
other.query == query &&
other.tenantSlug == tenantSlug &&
other.department == department &&
listEquals(other.tenantSlugs, tenantSlugs);
}
@override
int get hashCode =>
Object.hash(query, tenantSlug, department, Object.hashAll(tenantSlugs));
}
abstract class DirectoryRepository {
const DirectoryRepository();
Future<List<Employee>> loadEmployees(DirectoryQuery query);
}
abstract class EmployeeDetailRepository {
const EmployeeDetailRepository();
Future<EmployeeDetail> loadEmployeeDetail(String employeeId);
}
class RemoteDirectoryRepository implements DirectoryRepository {
const RemoteDirectoryRepository({required this.orgContextApiClient});
final OrgContextApiClient orgContextApiClient;
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
final snapshot = await orgContextApiClient.fetchOrgContext(
tenantSlug: query.apiTenantSlug,
);
final employees = snapshot.employees.where((employee) {
return _matchesTenant(employee, query.apiTenantSlugs) &&
_matchesDepartment(employee, query.apiDepartment) &&
_matchesQuery(employee, query.apiQuery);
}).toList();
employees.sort(_compareEmployees);
return employees;
}
bool _matchesTenant(Employee employee, List<String>? tenantSlugs) {
return tenantSlugs == null || tenantSlugs.contains(employee.tenantSlug);
}
bool _matchesDepartment(Employee employee, String? department) {
if (department == null) {
return true;
}
return employee.department == department ||
employee.tenantName == department;
}
bool _matchesQuery(Employee employee, String? query) {
if (query == null) {
return true;
}
final normalizedQuery = query.toLowerCase();
final queryDigits = query.replaceAll(RegExp(r'[^0-9]'), '');
final fields = [
employee.name,
employee.phoneNumber,
employee.phoneDisplay,
employee.email,
employee.tenantName,
employee.department,
employee.grade,
employee.position,
employee.jobTitle,
].whereType<String>();
return fields.any((field) {
final normalizedField = field.toLowerCase();
if (normalizedField.contains(normalizedQuery)) {
return true;
}
if (queryDigits.isEmpty) {
return false;
}
final fieldDigits = field.replaceAll(RegExp(r'[^0-9]'), '');
return fieldDigits.contains(queryDigits);
});
}
}
class RemoteEmployeeDetailRepository implements EmployeeDetailRepository {
const RemoteEmployeeDetailRepository({required this.directoryApiClient});
final DirectoryApiClient directoryApiClient;
@override
Future<EmployeeDetail> loadEmployeeDetail(String employeeId) {
return directoryApiClient.getEmployee(employeeId);
}
}
int _compareEmployees(Employee left, Employee right) {
final tenantCompare = left.tenantName.compareTo(right.tenantName);
if (tenantCompare != 0) {
return tenantCompare;
}
final departmentCompare = (left.department ?? '').compareTo(
right.department ?? '',
);
if (departmentCompare != 0) {
return departmentCompare;
}
final leaderCompare = _leaderPriority(left).compareTo(
_leaderPriority(right),
);
if (leaderCompare != 0) {
return leaderCompare;
}
final rankCompare = _rankPriority(left).compareTo(_rankPriority(right));
if (rankCompare != 0) {
return rankCompare;
}
final nameCompare = left.name.compareTo(right.name);
if (nameCompare != 0) {
return nameCompare;
}
return (left.sortOrder ?? 1 << 30).compareTo(right.sortOrder ?? 1 << 30);
}
int _leaderPriority(Employee employee) {
if (employee.isManager) {
return 0;
}
final position = employee.position?.trim() ?? '';
return position.contains('팀장') ? 0 : 1;
}
int _rankPriority(Employee employee) {
const priorityByToken = {
'사장': 0,
'부사장': 1,
'수석': 2,
'전무': 3,
'상무': 4,
'이사': 5,
'책임': 6,
'부장': 7,
'선임': 8,
'과장': 9,
'대리': 10,
'사원': 11,
};
final values = [
employee.grade?.trim() ?? '',
employee.position?.trim() ?? '',
employee.jobTitle?.trim() ?? '',
];
for (final value in values) {
for (final entry in priorityByToken.entries) {
if (value.contains(entry.key)) {
return entry.value;
}
}
}
return priorityByToken.length + 1;
}
final directoryRepositoryProvider = Provider<DirectoryRepository>((ref) {
return RemoteDirectoryRepository(
orgContextApiClient: ref.watch(orgContextApiClientProvider),
);
});
final employeeDetailRepositoryProvider = Provider<EmployeeDetailRepository>((ref) {
return RemoteEmployeeDetailRepository(
directoryApiClient: ref.watch(directoryApiClientProvider),
);
});
final directoryEmployeesProvider = FutureProvider.autoDispose
.family<List<Employee>, DirectoryQuery>((ref, query) {
return ref.watch(directoryRepositoryProvider).loadEmployees(query);
});
@@ -0,0 +1,216 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../../core/config/app_environment_provider.dart';
import '../../../core/network/http_client_provider.dart';
import '../../auth/data/auth_session_store.dart';
import '../domain/employee.dart';
class ProfileImageApiClient {
ProfileImageApiClient({
required this.httpClient,
required this.baseUri,
required this.sessionStore,
this.timeout = const Duration(seconds: 5),
this.cacheTtl = const Duration(minutes: 10),
});
static final Map<String, _ProfileImageCacheEntry> _cache = {};
static final RegExp _uuidPattern = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
caseSensitive: false,
);
final http.Client httpClient;
final Uri baseUri;
final AuthSessionStore sessionStore;
final Duration timeout;
final Duration cacheTtl;
Future<String?> resolveImageUrl(Employee employee) async {
final explicitUrl = employee.profileImageUrl?.trim() ?? '';
if (explicitUrl.isNotEmpty) {
return explicitUrl;
}
final uuidFallbackUrl = _uuidFallbackUrl(employee.id);
final email = employee.email?.trim().toLowerCase() ?? '';
if (email.isEmpty) {
_debug(
'skip auth lookup: employee=${employee.name}, id=${employee.id}, email missing, uuidFallback=${uuidFallbackUrl != null}',
);
return uuidFallbackUrl;
}
final session = await sessionStore.load();
if (session == null || session.isExpired || session.token.trim().isEmpty) {
_debug(
'skip auth lookup: employee=${employee.name}, email=$email, session unavailable, uuidFallback=${uuidFallbackUrl != null}',
);
return uuidFallbackUrl;
}
final companyCode = _resolveCompanyCode(employee, email: email);
final cacheKey = '${companyCode ?? ''}|$email';
final now = DateTime.now().toUtc();
final cached = _cache[cacheKey];
if (cached != null && cached.expiresAt.isAfter(now)) {
final resolved = await cached.future;
return resolved ?? uuidFallbackUrl;
}
final future = _fetchImageUrl(
companyCode: companyCode,
email: email,
accessToken: session.token.trim(),
);
_cache[cacheKey] = _ProfileImageCacheEntry(
future: future,
expiresAt: now.add(cacheTtl),
);
try {
final resolved = await future;
if (resolved == null && uuidFallbackUrl != null) {
_debug(
'auth lookup empty: employee=${employee.name}, email=$email, fallback to uuid url',
);
}
return resolved ?? uuidFallbackUrl;
} catch (_) {
if (identical(_cache[cacheKey]?.future, future)) {
_cache.remove(cacheKey);
}
if (uuidFallbackUrl != null) {
_debug(
'auth lookup failed: employee=${employee.name}, email=$email, fallback to uuid url',
);
}
return uuidFallbackUrl;
}
}
Future<String?> _fetchImageUrl({
required String? companyCode,
required String email,
required String accessToken,
}) async {
final appSessionToken = accessToken.trim();
final queryParameters = <String, String>{'email': email};
if (companyCode != null && companyCode.isNotEmpty) {
queryParameters['comp'] = companyCode;
}
_debug(
'request profile image: email=$email, comp=${companyCode ?? ''}, base=${baseUri.toString()}',
);
final response = await httpClient
.get(
_resolve('/api/v1/profile-image', queryParameters: queryParameters),
headers: {
'accept': 'application/json',
if (appSessionToken.isNotEmpty)
'Authorization': 'Bearer $appSessionToken',
if (appSessionToken.isNotEmpty) 'X-App-Session': appSessionToken,
},
)
.timeout(timeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
_debug(
'profile image response not ok: email=$email, status=${response.statusCode}',
);
return null;
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
return null;
}
final found = decoded['found'] as bool? ?? false;
final imageUrl = decoded['imageUrl'] as String? ?? '';
if (!found || imageUrl.trim().isEmpty) {
_debug('profile image not found: email=$email');
return null;
}
_debug('profile image resolved: email=$email, url=${imageUrl.trim()}');
return imageUrl.trim();
}
Uri _resolve(
String path, {
Map<String, String> queryParameters = const {},
}) {
final normalizedBase = baseUri.path.endsWith('/')
? baseUri
: baseUri.replace(path: '${baseUri.path}/');
final resolved = normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
return resolved.replace(queryParameters: queryParameters);
}
String? _uuidFallbackUrl(String employeeId) {
final normalized = employeeId.trim();
if (!_uuidPattern.hasMatch(normalized)) {
return null;
}
return 'https://baroncs.co.kr/employee_img/$normalized.jpg';
}
static void _debug(String message) {
if (kDebugMode) {
debugPrint('[ProfileImageApiClient] $message');
}
}
}
String? _resolveCompanyCode(Employee employee, {required String email}) {
final tenantSlug = employee.tenantSlug.trim().toLowerCase();
final tenantName = employee.tenantName.trim().toLowerCase();
if (tenantSlug.contains('saman') || tenantName.contains('삼안')) {
return 'SAMAN';
}
if (tenantSlug.contains('hanmac') || tenantName.contains('한맥')) {
return 'HANMAC';
}
if (tenantSlug == 'ptc' || tenantName == 'ptc') {
return 'PTC';
}
if (tenantSlug == 'tdc' || tenantName == 'tdc') {
return 'TDC';
}
if (email.endsWith('@samaneng.com')) {
return 'SAMAN';
}
if (email.endsWith('@hanmaceng.co.kr')) {
return 'HANMAC';
}
return null;
}
class _ProfileImageCacheEntry {
const _ProfileImageCacheEntry({
required this.future,
required this.expiresAt,
});
final Future<String?> future;
final DateTime expiresAt;
}
final profileImageApiClientProvider = Provider<ProfileImageApiClient>((ref) {
final environment = ref.watch(appEnvironmentProvider);
return ProfileImageApiClient(
httpClient: ref.watch(httpClientProvider),
baseUri: Uri.parse(environment.authApiBaseUrl),
sessionStore: ref.watch(authSessionStoreProvider),
);
});
@@ -15,6 +15,7 @@ class Employee {
this.status,
this.profileImageUrl,
this.sortOrder,
this.isManager = false,
});
final String id;
@@ -32,6 +33,7 @@ class Employee {
final String? status;
final String? profileImageUrl;
final int? sortOrder;
final bool isManager;
factory Employee.fromJson(Map<String, dynamic> json) {
return Employee(
@@ -50,6 +52,7 @@ class Employee {
status: json['status'] as String?,
profileImageUrl: json['profileImageUrl'] as String?,
sortOrder: json['sortOrder'] as int?,
isManager: json['isManager'] as bool? ?? false,
);
}
@@ -70,6 +73,7 @@ class Employee {
'status': status,
'profileImageUrl': profileImageUrl,
'sortOrder': sortOrder,
'isManager': isManager,
};
}
}
@@ -151,6 +155,7 @@ class EmployeeDetail extends Employee {
super.status,
super.profileImageUrl,
super.sortOrder,
super.isManager,
required this.joinedTenants,
required this.actions,
});
@@ -175,6 +180,7 @@ class EmployeeDetail extends Employee {
status: json['status'] as String?,
profileImageUrl: json['profileImageUrl'] as String?,
sortOrder: json['sortOrder'] as int?,
isManager: json['isManager'] as bool? ?? false,
joinedTenants: (json['joinedTenants'] as List<dynamic>? ?? [])
.map((item) => TenantRef.fromJson(item as Map<String, dynamic>))
.toList(),
@@ -0,0 +1,228 @@
import '../../organization/domain/organization_models.dart';
class TenantNavigationModel {
const TenantNavigationModel({
required this.selectedTenant,
required this.selectedCompanyTenant,
required this.pinnedTenant,
required this.companyTenants,
required this.breadcrumbTenants,
required this.visibleChips,
required this.childTenants,
required this.selectedTenantSubtreeSlugs,
});
final TenantSummary? selectedTenant;
final TenantSummary? selectedCompanyTenant;
final TenantSummary? pinnedTenant;
final List<TenantSummary> companyTenants;
final List<TenantSummary> breadcrumbTenants;
final List<TenantSummary> visibleChips;
final List<TenantSummary> childTenants;
final List<String> selectedTenantSubtreeSlugs;
bool get showsCompanyDirectory => selectedTenant == null;
bool get hasChildren => childTenants.isNotEmpty;
}
TenantNavigationModel buildTenantNavigationModel({
required List<TenantSummary> tenants,
required String selectedTenantSlug,
required String pinnedTenantSlug,
}) {
final selectedTenant = _findTenantBySlug(tenants, selectedTenantSlug);
final pinnedTenant = _findTenantBySlug(tenants, pinnedTenantSlug);
final companyTenants = [
for (final tenant in tenants)
if (tenant.type == 'COMPANY') tenant,
];
final breadcrumbTenants = selectedTenant == null
? const <TenantSummary>[]
: _buildBreadcrumbTenants(tenants, selectedTenant);
final selectedCompanyTenant = _resolveSelectedCompanyTenant(
selectedTenant: selectedTenant,
breadcrumbTenants: breadcrumbTenants,
);
final childTenants = selectedTenant == null
? companyTenants
: [
for (final tenant in tenants)
if (tenant.parentId == selectedTenant.id) tenant,
];
final selectedTenantSubtreeSlugs = selectedTenant == null
? const <String>[]
: buildTenantSubtreeSlugs(tenants: tenants, rootTenant: selectedTenant);
final visibleChips = <TenantSummary>[];
final seenSlugs = <String>{};
void addChip(TenantSummary tenant) {
if (tenant.slug.trim().isEmpty || !seenSlugs.add(tenant.slug)) {
return;
}
visibleChips.add(tenant);
}
for (final tenant in companyTenants) {
addChip(tenant);
}
if (pinnedTenant != null && pinnedTenant.type != 'COMPANY') {
addChip(pinnedTenant);
}
for (final tenant in breadcrumbTenants) {
if (tenant.type != 'COMPANY') {
addChip(tenant);
}
}
return TenantNavigationModel(
selectedTenant: selectedTenant,
selectedCompanyTenant: selectedCompanyTenant,
pinnedTenant: pinnedTenant,
companyTenants: companyTenants,
breadcrumbTenants: breadcrumbTenants,
visibleChips: visibleChips,
childTenants: childTenants,
selectedTenantSubtreeSlugs: selectedTenantSubtreeSlugs,
);
}
List<String> buildTenantSubtreeSlugs({
required List<TenantSummary> tenants,
required TenantSummary rootTenant,
}) {
final tenantsByParentId = <String, List<TenantSummary>>{};
for (final tenant in tenants) {
final parentId = tenant.parentId;
if (parentId == null || parentId.trim().isEmpty) {
continue;
}
tenantsByParentId
.putIfAbsent(parentId, () => <TenantSummary>[])
.add(tenant);
}
final slugs = <String>[];
final queue = <TenantSummary>[rootTenant];
final visitedIds = <String>{};
while (queue.isNotEmpty) {
final current = queue.removeAt(0);
if (!visitedIds.add(current.id)) {
continue;
}
if (current.slug.trim().isNotEmpty) {
slugs.add(current.slug);
}
queue.addAll(tenantsByParentId[current.id] ?? const <TenantSummary>[]);
}
return slugs;
}
String resolvePinnedTenantSlug({
required List<TenantSummary> tenants,
required String companySlug,
required String? departmentName,
}) {
final normalizedDepartment = departmentName?.trim();
if (normalizedDepartment == null || normalizedDepartment.isEmpty) {
return companySlug;
}
final companyTenant = _findTenantBySlug(tenants, companySlug);
if (companyTenant == null) {
return companySlug;
}
final match = _findDescendantTenantByName(
tenants: tenants,
rootTenantId: companyTenant.id,
tenantName: normalizedDepartment,
);
return match?.slug ?? companySlug;
}
List<TenantSummary> _buildBreadcrumbTenants(
List<TenantSummary> tenants,
TenantSummary selectedTenant,
) {
final tenantsById = {for (final tenant in tenants) tenant.id: tenant};
final reversed = <TenantSummary>[selectedTenant];
var current = selectedTenant;
while (current.parentId != null && current.parentId!.trim().isNotEmpty) {
final parent = tenantsById[current.parentId!];
if (parent == null) {
break;
}
reversed.add(parent);
current = parent;
}
return reversed.reversed.toList(growable: false);
}
TenantSummary? _findTenantBySlug(List<TenantSummary> tenants, String slug) {
for (final tenant in tenants) {
if (tenant.slug == slug) {
return tenant;
}
}
return null;
}
TenantSummary? _resolveSelectedCompanyTenant({
required TenantSummary? selectedTenant,
required List<TenantSummary> breadcrumbTenants,
}) {
if (selectedTenant == null) {
return null;
}
if (selectedTenant.type == 'COMPANY') {
return selectedTenant;
}
for (final tenant in breadcrumbTenants) {
if (tenant.type == 'COMPANY') {
return tenant;
}
}
return null;
}
TenantSummary? _findDescendantTenantByName({
required List<TenantSummary> tenants,
required String rootTenantId,
required String tenantName,
}) {
final normalizedName = tenantName.trim().toLowerCase();
if (normalizedName.isEmpty) {
return null;
}
final tenantsByParentId = <String, List<TenantSummary>>{};
for (final tenant in tenants) {
final parentId = tenant.parentId;
if (parentId == null || parentId.trim().isEmpty) {
continue;
}
tenantsByParentId
.putIfAbsent(parentId, () => <TenantSummary>[])
.add(tenant);
}
final queue = <String>[rootTenantId];
final visited = <String>{};
while (queue.isNotEmpty) {
final currentId = queue.removeAt(0);
if (!visited.add(currentId)) {
continue;
}
for (final tenant
in tenantsByParentId[currentId] ?? const <TenantSummary>[]) {
if (tenant.name.trim().toLowerCase() == normalizedName) {
return tenant;
}
queue.add(tenant.id);
}
}
return null;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../domain/favorite_employee.dart';
abstract class FavoritesRepository {
const FavoritesRepository();
Future<List<FavoriteEmployee>> loadFavorites();
Future<void> toggleFavorite(String employeeId);
}
class SharedPreferencesFavoritesRepository implements FavoritesRepository {
SharedPreferencesFavoritesRepository({
required SharedPreferencesAsync preferences,
}) : this.withStore(SharedPreferencesFavoritesStore(preferences));
const SharedPreferencesFavoritesRepository.withStore(this.store);
static const _storageKey = 'tdc114plus.favoriteEmployees';
final FavoritesKeyValueStore store;
@override
Future<List<FavoriteEmployee>> loadFavorites() async {
final encoded = await store.getString(_storageKey);
if (encoded == null || encoded.isEmpty) {
return const [];
}
final decoded = jsonDecode(encoded) as List<dynamic>;
return decoded
.map((item) => FavoriteEmployee.fromJson(item as Map<String, dynamic>))
.where((favorite) => favorite.employeeId.isNotEmpty)
.toList();
}
@override
Future<void> toggleFavorite(String employeeId) async {
final normalizedId = employeeId.trim();
if (normalizedId.isEmpty) {
return;
}
final favorites = await loadFavorites();
final nextFavorites = favorites
.where((favorite) => favorite.employeeId != normalizedId)
.toList();
if (nextFavorites.length == favorites.length) {
nextFavorites.add(
FavoriteEmployee(employeeId: normalizedId, createdAt: DateTime.now()),
);
}
await store.setString(
_storageKey,
jsonEncode(nextFavorites.map((favorite) => favorite.toJson()).toList()),
);
}
}
abstract class FavoritesKeyValueStore {
const FavoritesKeyValueStore();
Future<String?> getString(String key);
Future<void> setString(String key, String value);
}
class SharedPreferencesFavoritesStore implements FavoritesKeyValueStore {
const SharedPreferencesFavoritesStore(this._preferences);
final SharedPreferencesAsync _preferences;
@override
Future<String?> getString(String key) {
return _preferences.getString(key);
}
@override
Future<void> setString(String key, String value) {
return _preferences.setString(key, value);
}
}
final favoritesRepositoryProvider = Provider<FavoritesRepository>((ref) {
return SharedPreferencesFavoritesRepository(
preferences: SharedPreferencesAsync(),
);
});
final favoriteEmployeeIdsProvider = FutureProvider<Set<String>>((ref) async {
final favorites = await ref
.watch(favoritesRepositoryProvider)
.loadFavorites();
return favorites.map((favorite) => favorite.employeeId).toSet();
});
@@ -0,0 +1,503 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../../core/config/app_environment_provider.dart';
import '../../../core/network/api_error.dart';
import '../../../core/network/http_client_provider.dart';
import '../../directory/domain/employee.dart';
import '../../auth/data/auth_session_store.dart';
import '../domain/organization_models.dart';
class OrgContextApiClient {
const OrgContextApiClient({
required this.httpClient,
required this.baseUri,
required this.keyId,
required this.keySecret,
required this.tenantSlug,
this.sessionStore,
this.timeout = const Duration(seconds: 15),
this.cacheTtl = const Duration(minutes: 5),
});
static final Map<String, _OrgContextCacheEntry> _cache = {};
final http.Client httpClient;
final Uri baseUri;
final String keyId;
final String keySecret;
final String tenantSlug;
final AuthSessionStore? sessionStore;
final Duration timeout;
final Duration cacheTtl;
Future<OrgChartSnapshot> fetchOrgContext({
bool forceRefresh = false,
String? tenantSlug,
}) async {
final credential = await _activeCredential(tenantSlug: tenantSlug);
final cacheKey = credential.cacheKey;
final now = DateTime.now().toUtc();
final cached = _cache[cacheKey];
if (!forceRefresh && cached != null && cached.expiresAt.isAfter(now)) {
return cached.future;
}
final future = _fetchOrgContext(credential);
_cache[cacheKey] = _OrgContextCacheEntry(
future: future,
expiresAt: now.add(cacheTtl),
);
try {
return await future;
} catch (_) {
if (identical(_cache[cacheKey]?.future, future)) {
_cache.remove(cacheKey);
}
rethrow;
}
}
static void clearCacheForTesting() {
_cache.clear();
}
Future<OrgChartSnapshot> _fetchOrgContext(
_OrgContextCredentialSnapshot credential,
) async {
final response = await httpClient
.get(
_resolve(
credential.baseUri,
'/api/v1/integrations/org-context',
queryParameters: {
'tenantSlug': credential.tenantSlug,
'includeUsers': 'true',
'includeUserIds': 'true',
},
),
headers: _headers(credential),
)
.timeout(timeout);
return _toSnapshot(_decodeOk(response));
}
Future<_OrgContextCredentialSnapshot> _activeCredential({
String? tenantSlug,
}) async {
final session = await _session();
final sessionCredential = session?.orgContextCredential;
final requestedTenantSlug = tenantSlug?.trim();
if (sessionCredential != null && sessionCredential.isUsable) {
return _OrgContextCredentialSnapshot(
baseUri: Uri.parse(sessionCredential.baseUrl),
tenantSlug: requestedTenantSlug == null || requestedTenantSlug.isEmpty
? sessionCredential.tenantSlug
: requestedTenantSlug,
keyId: sessionCredential.keyId,
keySecret: sessionCredential.keySecret,
appSessionToken: session?.token ?? '',
);
}
return _OrgContextCredentialSnapshot(
baseUri: baseUri,
tenantSlug: requestedTenantSlug == null || requestedTenantSlug.isEmpty
? this.tenantSlug
: requestedTenantSlug,
keyId: keyId,
keySecret: keySecret,
appSessionToken: session?.token ?? '',
);
}
Future<StoredAuthSession?> _session() async {
final store = sessionStore;
if (store == null) {
debugPrint('OrgContextApiClient._session no sessionStore');
return null;
}
final session = await store.load();
if (session == null || session.isExpired) {
debugPrint(
'OrgContextApiClient._session unavailable session=${session != null} expired=${session?.isExpired ?? true}',
);
return null;
}
debugPrint(
'OrgContextApiClient._session tokenLength=${session.token.length} expiresAt=${session.expiresAt.toUtc().toIso8601String()}',
);
return session;
}
Map<String, String> _headers(_OrgContextCredentialSnapshot credential) {
final appSessionToken = credential.appSessionToken.trim();
final headers = {
'accept': 'application/json',
if (appSessionToken.isNotEmpty) 'Authorization': 'Bearer $appSessionToken',
if (appSessionToken.isNotEmpty) 'X-App-Session': appSessionToken,
if (credential.keyId.trim().isNotEmpty)
'X-Baron-Key-ID': credential.keyId.trim(),
if (credential.keySecret.trim().isNotEmpty)
'X-Baron-Key-Secret': credential.keySecret.trim(),
};
debugPrint(
'OrgContextApiClient._headers headers=$headers tenantSlug=${credential.tenantSlug} base=${credential.baseUri}',
);
return headers;
}
Map<String, dynamic> _decodeOk(http.Response response) {
final decoded = _decodeObject(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
statusCode: response.statusCode,
apiError: ApiError.fromJson(decoded),
);
}
return decoded;
}
Uri _resolve(
Uri baseUri,
String path, {
Map<String, String?> queryParameters = const {},
}) {
final normalizedBase = baseUri.path.endsWith('/')
? baseUri
: baseUri.replace(path: '${baseUri.path}/');
final uri = normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
final filteredQuery = Map<String, String>.fromEntries(
queryParameters.entries
.where((entry) {
return entry.value != null && entry.value!.trim().isNotEmpty;
})
.map((entry) => MapEntry(entry.key, entry.value!)),
);
return uri.replace(queryParameters: filteredQuery);
}
Map<String, dynamic> _decodeObject(String body) {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
return decoded;
}
throw const FormatException('Expected JSON object response');
}
OrgChartSnapshot _toSnapshot(Map<String, dynamic> json) {
final issuedAt = _parseDate(json['issuedAt']) ?? DateTime.now().toUtc();
final tenantById = <String, TenantSummary>{};
final ownMemberCountByTenantId = <String, int>{};
final tree = json['tree'];
if (tree is Map<String, dynamic>) {
_collectTenantTree(tree, tenantById);
}
final tenantItems = <Map<String, dynamic>>[];
for (final item in json['tenants'] as List<dynamic>? ?? const []) {
if (item is! Map<String, dynamic>) {
continue;
}
tenantItems.add(item);
final tenant = _tenantFromJson(item);
if (tenant.id.isNotEmpty) {
tenantById[tenant.id] = tenant;
ownMemberCountByTenantId[tenant.id] = _memberList(item).length;
}
}
final employeesByMembership = <String, Employee>{};
for (final item in tenantItems) {
final tenant = _tenantFromJson(item);
if (tenant.id.isNotEmpty) {
tenantById[tenant.id] = tenant;
}
for (final employee in _employeesFromTenant(item, tenant)) {
if (employee.id.isNotEmpty) {
employeesByMembership['${tenant.id}:${employee.id}'] = employee;
}
}
}
final countedTenantById = _recountTenantMemberCounts(
tenantById: tenantById,
ownMemberCountByTenantId: ownMemberCountByTenantId,
);
final tenants = countedTenantById.values.toList()
..sort((a, b) {
final parentCompare = (a.parentId ?? '').compareTo(b.parentId ?? '');
if (parentCompare != 0) {
return parentCompare;
}
return a.name.compareTo(b.name);
});
final employees = employeesByMembership.values.toList();
return OrgChartSnapshot(
tenants: tenants,
employees: employees,
generatedAt: issuedAt.toUtc(),
cache: const OrgChartCacheInfo(source: 'baron-org-context', hit: false),
);
}
Map<String, TenantSummary> _recountTenantMemberCounts({
required Map<String, TenantSummary> tenantById,
required Map<String, int> ownMemberCountByTenantId,
}) {
final childrenByParentId = <String, List<TenantSummary>>{};
for (final tenant in tenantById.values) {
final parentId = tenant.parentId;
if (parentId == null || parentId.trim().isEmpty) {
continue;
}
childrenByParentId
.putIfAbsent(parentId, () => <TenantSummary>[])
.add(tenant);
}
final totalCountByTenantId = <String, int>{};
int totalFor(String tenantId) {
final cached = totalCountByTenantId[tenantId];
if (cached != null) {
return cached;
}
final ownCount =
ownMemberCountByTenantId[tenantId] ??
tenantById[tenantId]?.memberCount ??
0;
final total = (childrenByParentId[tenantId] ?? const <TenantSummary>[])
.fold<int>(ownCount, (sum, child) => sum + totalFor(child.id));
totalCountByTenantId[tenantId] = total;
return total;
}
return {
for (final entry in tenantById.entries)
entry.key: _copyTenantWithCounts(
entry.value,
memberCount:
ownMemberCountByTenantId[entry.key] ?? entry.value.memberCount,
totalMemberCount: totalFor(entry.key),
),
};
}
TenantSummary _copyTenantWithCounts(
TenantSummary tenant, {
required int memberCount,
required int totalMemberCount,
}) {
return TenantSummary(
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
type: tenant.type,
parentId: tenant.parentId,
memberCount: memberCount,
totalMemberCount: totalMemberCount,
);
}
void _collectTenantTree(
Map<String, dynamic> json,
Map<String, TenantSummary> tenantById,
) {
final tenant = _tenantFromJson(json);
if (tenant.id.isNotEmpty) {
tenantById[tenant.id] = tenant;
}
final children = json['children'];
if (children is List<dynamic>) {
for (final child in children) {
if (child is Map<String, dynamic>) {
_collectTenantTree(child, tenantById);
}
}
}
}
TenantSummary _tenantFromJson(Map<String, dynamic> json) {
final memberCount = _readInt(json['memberCount']);
final totalMemberCount = _readInt(json['totalMemberCount']) ?? memberCount;
return TenantSummary(
id: _readString(json, ['id']),
name: _readString(json, ['name']),
slug: _readString(json, ['slug']),
type: _readString(json, ['type', 'orgUnitType']),
parentId: _nullableString(json['parentId']),
memberCount: memberCount ?? _memberList(json).length,
totalMemberCount: totalMemberCount ?? _memberList(json).length,
);
}
List<Employee> _employeesFromTenant(
Map<String, dynamic> tenantJson,
TenantSummary tenant,
) {
final members = _memberList(tenantJson);
return members.indexed.map((entry) {
final index = entry.$1;
final member = entry.$2;
final rawPhone = _readString(member, [
'phoneNumber',
'phone',
'mobile',
'mobilePhone',
]);
final email = _nullableString(member['email']);
final memberId = _readString(member, [
'id',
'userId',
'employeeId',
'loginId',
]);
final id = memberId.isNotEmpty
? memberId
: [
tenant.id,
email,
_readString(member, ['name']),
rawPhone,
].whereType<String>().where((value) => value.isNotEmpty).join(':');
return Employee(
id: id,
name: _readString(member, ['name']),
phoneNumber: rawPhone,
phoneDisplay: _formatPhone(rawPhone),
email: email,
tenantId: tenant.id,
tenantName: tenant.name,
tenantSlug: tenant.slug,
department: _nullableString(member['department']),
grade: _nullableString(member['grade']),
position: _nullableString(member['position']),
jobTitle: _nullableString(member['jobTitle']),
status: _nullableString(member['status']),
profileImageUrl: _nullableString(member['profileImageUrl']),
sortOrder: _readInt(member['sortOrder']) ?? index,
isManager: member['isManager'] as bool? ?? false,
);
}).toList();
}
List<Map<String, dynamic>> _memberList(Map<String, dynamic> json) {
final members = json['members'];
if (members is! List<dynamic>) {
return const [];
}
return members
.whereType<Map>()
.map((member) => Map<String, dynamic>.from(member))
.toList();
}
DateTime? _parseDate(Object? value) {
if (value is! String || value.trim().isEmpty) {
return null;
}
return DateTime.tryParse(value);
}
int? _readInt(Object? value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
if (value is String) {
return int.tryParse(value);
}
return null;
}
String _readString(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final value = _nullableString(json[key]);
if (value != null) {
return value;
}
}
return '';
}
String? _nullableString(Object? value) {
if (value is! String) {
return null;
}
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
String? _formatPhone(String value) {
var digits = value.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.startsWith('82') && digits.length >= 11) {
digits = '0${digits.substring(2)}';
}
if (digits.length == 11 && digits.startsWith('010')) {
return '${digits.substring(0, 3)}-${digits.substring(3, 7)}-${digits.substring(7)}';
}
return value.trim().isEmpty ? null : value.trim();
}
}
final orgContextApiClientProvider = Provider<OrgContextApiClient>((ref) {
final environment = ref.watch(appEnvironmentProvider);
return OrgContextApiClient(
httpClient: ref.watch(httpClientProvider),
baseUri: Uri.parse(environment.orgContextApiBaseUrl),
keyId: environment.baronKeyId,
keySecret: environment.baronKeySecret,
tenantSlug: environment.orgContextTenantSlug,
sessionStore: ref.watch(authSessionStoreProvider),
);
});
final orgContextSnapshotProvider = FutureProvider.autoDispose<OrgChartSnapshot>(
(ref) {
return ref.watch(orgContextApiClientProvider).fetchOrgContext();
},
);
class _OrgContextCredentialSnapshot {
const _OrgContextCredentialSnapshot({
required this.baseUri,
required this.tenantSlug,
required this.keyId,
required this.keySecret,
required this.appSessionToken,
});
final Uri baseUri;
final String tenantSlug;
final String keyId;
final String keySecret;
final String appSessionToken;
String get cacheKey {
return [
baseUri.toString(),
tenantSlug,
appSessionToken.isEmpty ? keyId : appSessionToken,
].join('|');
}
}
class _OrgContextCacheEntry {
const _OrgContextCacheEntry({required this.future, required this.expiresAt});
final Future<OrgChartSnapshot> future;
final DateTime expiresAt;
}
@@ -0,0 +1,195 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../../core/config/app_environment_provider.dart';
import '../../../core/network/api_error.dart';
import '../../../core/network/http_client_provider.dart';
import '../../auth/data/auth_session_store.dart';
import '../domain/organization_models.dart';
import 'org_context_api_client.dart';
abstract class OrganizationRepository {
const OrganizationRepository();
Future<TenantListResponse> listTenants();
Future<OrgChartSnapshot> getOrgChart({
String? tenantId,
bool refresh = false,
});
}
class OrganizationApiClient {
const OrganizationApiClient({
required this.httpClient,
required this.baseUri,
required this.sessionStore,
this.timeout = const Duration(seconds: 10),
});
final http.Client httpClient;
final Uri baseUri;
final AuthSessionStore sessionStore;
final Duration timeout;
Future<TenantListResponse> listTenants() async {
final response = await httpClient
.get(
_resolve('/api/v1/tdc114plus/organization/tenants'),
headers: await _headers(),
)
.timeout(timeout);
return TenantListResponse.fromJson(_decodeOk(response));
}
Future<OrgChartSnapshot> getOrgChart({
String? tenantId,
bool refresh = false,
}) async {
final response = await httpClient
.get(
_resolve(
'/api/v1/tdc114plus/organization/orgchart',
queryParameters: {
'tenantId': tenantId,
if (refresh) 'refresh': 'true',
},
),
headers: await _headers(),
)
.timeout(timeout);
return OrgChartSnapshot.fromJson(_decodeOk(response));
}
Future<Map<String, String>> _headers() async {
final session = await sessionStore.load();
return {
'accept': 'application/json',
if (session?.token != null) 'authorization': 'Bearer ${session!.token}',
};
}
Map<String, dynamic> _decodeOk(http.Response response) {
final decoded = _decodeObject(response.body);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
statusCode: response.statusCode,
apiError: ApiError.fromJson(decoded),
);
}
return decoded;
}
Uri _resolve(String path, {Map<String, String?> queryParameters = const {}}) {
final normalizedBase = baseUri.path.endsWith('/')
? baseUri
: baseUri.replace(path: '${baseUri.path}/');
final uri = normalizedBase.resolve(path.replaceFirst(RegExp(r'^/'), ''));
final filteredQuery = Map<String, String>.fromEntries(
queryParameters.entries
.where((entry) {
return entry.value != null && entry.value!.trim().isNotEmpty;
})
.map((entry) => MapEntry(entry.key, entry.value!)),
);
return uri.replace(queryParameters: filteredQuery);
}
Map<String, dynamic> _decodeObject(String body) {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
return decoded;
}
throw const FormatException('Expected JSON object response');
}
}
class RemoteOrganizationRepository implements OrganizationRepository {
const RemoteOrganizationRepository({required this.apiClient});
final OrganizationApiClient apiClient;
@override
Future<TenantListResponse> listTenants() => apiClient.listTenants();
@override
Future<OrgChartSnapshot> getOrgChart({
String? tenantId,
bool refresh = false,
}) {
return apiClient.getOrgChart(tenantId: tenantId, refresh: refresh);
}
}
class OrgContextOrganizationRepository implements OrganizationRepository {
const OrgContextOrganizationRepository({required this.orgContextApiClient});
final OrgContextApiClient orgContextApiClient;
@override
Future<TenantListResponse> listTenants() async {
final snapshot = await orgContextApiClient.fetchOrgContext();
return TenantListResponse(
items: snapshot.tenants,
generatedAt: snapshot.generatedAt,
);
}
@override
Future<OrgChartSnapshot> getOrgChart({
String? tenantId,
bool refresh = false,
}) async {
final snapshot = await orgContextApiClient.fetchOrgContext();
if (tenantId == null || tenantId.trim().isEmpty) {
return snapshot;
}
return OrgChartSnapshot(
tenants: snapshot.tenants
.where(
(tenant) => tenant.id == tenantId || tenant.parentId == tenantId,
)
.toList(),
employees: snapshot.employees
.where((employee) => employee.tenantId == tenantId)
.toList(),
generatedAt: snapshot.generatedAt,
cache: snapshot.cache,
);
}
}
final organizationApiClientProvider = Provider<OrganizationApiClient>((ref) {
final environment = ref.watch(appEnvironmentProvider);
return OrganizationApiClient(
httpClient: ref.watch(httpClientProvider),
baseUri: Uri.parse(environment.organizationApiBaseUrl),
sessionStore: ref.watch(authSessionStoreProvider),
);
});
final organizationRepositoryProvider = Provider<OrganizationRepository>((ref) {
return OrgContextOrganizationRepository(
orgContextApiClient: ref.watch(orgContextApiClientProvider),
);
});
final tenantListProvider = FutureProvider.autoDispose<List<TenantSummary>>((
ref,
) async {
final response = await ref
.watch(organizationRepositoryProvider)
.listTenants();
return response.items;
});
final orgChartProvider = FutureProvider.autoDispose
.family<OrgChartSnapshot, String?>((ref, tenantId) async {
return ref
.watch(organizationRepositoryProvider)
.getOrgChart(tenantId: tenantId);
});
+95
View File
@@ -0,0 +1,95 @@
import '../features/directory/data/directory_repository.dart';
import '../features/directory/data/mock_directory_data.dart';
import '../features/directory/domain/employee.dart';
import '../features/organization/data/organization_api_client.dart';
import '../features/organization/domain/organization_models.dart';
const useSmokeMockDirectory = bool.fromEnvironment(
'TDC114_SMOKE_USE_MOCK_DIRECTORY',
);
final smokeDirectoryOverride = directoryRepositoryProvider.overrideWithValue(
const _SmokeDirectoryRepository(),
);
final smokeOrganizationOverride = organizationRepositoryProvider
.overrideWithValue(const _SmokeOrganizationRepository());
class _SmokeDirectoryRepository implements DirectoryRepository {
const _SmokeDirectoryRepository();
@override
Future<List<Employee>> loadEmployees(DirectoryQuery query) async {
final normalizedQuery = query.query.trim();
final normalizedDigits = _digitsOnly(normalizedQuery);
final selectedTenant = _findTenantBySlug(query.apiTenantSlug);
return mockEmployees.where((employee) {
final matchesTenant =
query.apiTenantSlug == null ||
employee.tenantSlug == query.apiTenantSlug ||
(selectedTenant != null &&
selectedTenant.type != 'COMPANY' &&
(employee.department ?? '') == selectedTenant.name);
final matchesDepartment =
query.apiDepartment == null ||
(employee.department ?? '') == query.apiDepartment;
if (!matchesTenant) {
return false;
}
if (!matchesDepartment) {
return false;
}
if (normalizedQuery.isEmpty) {
return true;
}
final matchesName = employee.name.contains(normalizedQuery);
final matchesDepartmentQuery = (employee.department ?? '').contains(
normalizedQuery,
);
final matchesPhone =
normalizedDigits.isNotEmpty &&
(_digitsOnly(employee.phoneNumber).contains(normalizedDigits) ||
_digitsOnly(
employee.phoneDisplay ?? '',
).contains(normalizedDigits));
return matchesName || matchesDepartmentQuery || matchesPhone;
}).toList();
}
String _digitsOnly(String value) => value.replaceAll(RegExp(r'[^0-9]'), '');
TenantSummary? _findTenantBySlug(String? slug) {
if (slug == null || slug.trim().isEmpty) {
return null;
}
for (final tenant in mockTenants) {
if (tenant.slug == slug) {
return tenant;
}
}
return null;
}
}
class _SmokeOrganizationRepository implements OrganizationRepository {
const _SmokeOrganizationRepository();
@override
Future<OrgChartSnapshot> getOrgChart({
String? tenantId,
bool refresh = false,
}) {
throw UnimplementedError();
}
@override
Future<TenantListResponse> listTenants() async {
return TenantListResponse(
items: mockTenants,
generatedAt: DateTime.parse('2026-07-02T00:00:00Z'),
);
}
}
+40 -1
View File
@@ -90,7 +90,7 @@ packages:
source: hosted
version: "1.15.1"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -158,6 +158,11 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
@@ -197,6 +202,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
@@ -237,6 +247,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl:
dependency: transitive
description:
@@ -397,6 +412,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.2"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.dev"
source: hosted
version: "5.0.5"
pub_semver:
dependency: transitive
description:
@@ -562,6 +585,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
term_glyph:
dependency: transitive
description:
@@ -722,6 +753,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
webkit_inspection_protocol:
dependency: transitive
description:
+3
View File
@@ -34,6 +34,7 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
crypto: ^3.0.6
easy_localization: ^3.0.7
flutter_riverpod: ^3.0.3
go_router: ^17.0.1
@@ -45,6 +46,8 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
+205
View File
@@ -0,0 +1,205 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:tdc114plus/src/core/network/api_error.dart';
import 'package:tdc114plus/src/features/auth/data/auth_api_client.dart';
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
void main() {
test('posts phone login request to tdc114plus namespace', () async {
final client = AuthApiClient(
httpClient: MockClient((request) async {
expect(
request.url.toString(),
'https://sso.example.test/api/v1/tdc114plus/auth/phone-login',
);
expect(request.method, 'POST');
expect(request.headers['content-type'], 'application/json');
expect(jsonDecode(request.body)['phoneNumber'], '01012345678');
return http.Response(
jsonEncode({
'status': 'ok',
'token': 'session-token',
'expiresAt': '2026-07-02T12:00:00Z',
'user': {
'id': 'user-uuid',
'name': 'User One',
'phoneNumber': '+821012345678',
'tenantId': 'tenant-uuid',
'tenantName': 'Hanmac',
'tenantSlug': 'hanmac',
},
}),
200,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://sso.example.test'),
);
final response = await client.phoneLogin(
const PhoneLoginRequest(
phoneNumber: '01012345678',
device: LoginDeviceInfo(
platform: 'android',
appVersion: '0.1.0',
deviceName: 'Pixel 8',
),
),
);
expect(response.token, 'session-token');
expect(response.user.tenantSlug, 'hanmac');
});
test('throws ApiException when login fails', () async {
final client = AuthApiClient(
httpClient: MockClient((request) async {
return http.Response(
jsonEncode({
'error': 'login failed',
'code': 'login_failed',
'details': {},
}),
401,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://sso.example.test'),
);
expect(
() => client.phoneLogin(
const PhoneLoginRequest(
phoneNumber: '01012345678',
device: LoginDeviceInfo(
platform: 'android',
appVersion: '0.1.0',
deviceName: 'Pixel 8',
),
),
),
throwsA(
isA<ApiException>()
.having((error) => error.statusCode, 'statusCode', 401)
.having((error) => error.code, 'code', 'login_failed'),
),
);
});
test('requests phone link from auth server namespace', () async {
final client = AuthApiClient(
httpClient: MockClient((request) async {
expect(
request.url.toString(),
'https://sso.example.test/api/v1/auth/link/init',
);
expect(request.method, 'POST');
expect(jsonDecode(request.body)['phoneNumber'], '01012345678');
return http.Response(
jsonEncode({
'status': 'pending',
'pendingRef': 'pending-ref',
'expiresIn': 180,
'pollInterval': 3,
'resendAfter': 30,
'provider': 'Ory (Kratos/Hydra)',
}),
200,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://sso.example.test'),
);
final response = await client.requestPhoneLoginLink(
const PhoneLoginLinkInitRequest(
phoneNumber: '01012345678',
device: LoginDeviceInfo(
platform: 'android',
appVersion: '0.1.0',
deviceName: 'Pixel 8',
),
),
);
expect(response.pendingRef, 'pending-ref');
expect(response.interval, 3);
});
test('polls headless phone link and parses completed session', () async {
final client = AuthApiClient(
httpClient: MockClient((request) async {
expect(
request.url.toString(),
'https://sso.example.test/api/v1/auth/link/poll',
);
expect(jsonDecode(request.body)['pendingRef'], 'pending-ref');
return http.Response(
jsonEncode({
'status': 'ok',
'session': {
'status': 'ok',
'accessToken': 'session-token',
'expiresAt': '2026-07-02T12:00:00Z',
'user': {
'id': 'user-uuid',
'name': 'User One',
'phoneNumber': '+821012345678',
'tenantId': 'tenant-uuid',
'tenantName': 'Hanmac',
'tenantSlug': 'hanmac',
},
},
}),
200,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://sso.example.test'),
);
final response = await client.pollPhoneLoginLink(
const PhoneLoginLinkPollRequest(pendingRef: 'pending-ref'),
);
expect(response.session?.token, 'session-token');
expect(response.session?.user.tenantSlug, 'hanmac');
});
test('parses completed auth-server poll response with top-level accessToken', () async {
final client = AuthApiClient(
httpClient: MockClient((request) async {
expect(
request.url.toString(),
'https://auth.example.test/api/v1/auth/link/poll',
);
return http.Response(
jsonEncode({
'status': 'ok',
'accessToken': 'app-session-jwt',
'expiresAt': '2026-07-13T16:32:56+09:00',
'user': {
'id': 'baron-user',
'name': 'Baron User',
'phoneNumber': '01091365338',
},
}),
200,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://auth.example.test'),
);
final response = await client.pollPhoneLoginLink(
const PhoneLoginLinkPollRequest(pendingRef: 'pending-ref'),
);
expect(response.status, 'ok');
expect(response.session?.token, 'app-session-jwt');
expect(response.session?.user.phoneNumber, '01091365338');
});
}
+141
View File
@@ -0,0 +1,141 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tdc114plus/src/core/network/api_error.dart';
import 'package:tdc114plus/src/features/auth/data/auth_api_client.dart';
import 'package:tdc114plus/src/features/auth/data/auth_repository.dart';
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('normalizes phone number and stores successful session', () async {
final repository = RemoteAuthRepository(
apiClient: AuthApiClient(
httpClient: MockClient((request) async {
expect(jsonDecode(request.body)['phoneNumber'], '01012345678');
return http.Response(
jsonEncode({
'status': 'ok',
'token': 'session-token',
'expiresAt': '2026-07-02T12:00:00Z',
'user': {
'id': 'user-uuid',
'name': 'User One',
'phoneNumber': '+821012345678',
'tenantId': 'tenant-uuid',
'tenantName': 'Hanmac',
'tenantSlug': 'hanmac',
},
}),
200,
);
}),
baseUri: Uri.parse('https://sso.example.test'),
),
sessionStore: const AuthSessionStore(),
appVersion: '0.1.0',
);
await repository.phoneLogin('010-1234-5678');
final session = await repository.loadSession();
expect(session?.token, 'session-token');
expect(session?.user.id, 'user-uuid');
});
test('rejects invalid phone number before calling API', () async {
var called = false;
final repository = RemoteAuthRepository(
apiClient: AuthApiClient(
httpClient: MockClient((request) async {
called = true;
return http.Response('{}', 200);
}),
baseUri: Uri.parse('https://sso.example.test'),
),
sessionStore: const AuthSessionStore(),
appVersion: '0.1.0',
);
expect(
() => repository.phoneLogin('123'),
throwsA(
isA<ApiException>().having(
(error) => error.code,
'code',
'invalid_phone_number',
),
),
);
expect(called, isFalse);
});
test('normalizes phone number before requesting login link', () async {
final repository = RemoteAuthRepository(
apiClient: AuthApiClient(
httpClient: MockClient((request) async {
expect(request.url.path, '/api/v1/auth/link/init');
expect(jsonDecode(request.body)['phoneNumber'], '01012345678');
return http.Response(
jsonEncode({
'status': 'pending',
'pendingRef': 'pending-ref',
'expiresIn': 180,
'pollInterval': 3,
'resendAfter': 30,
}),
200,
);
}),
baseUri: Uri.parse('https://sso.example.test'),
),
sessionStore: const AuthSessionStore(),
appVersion: '0.1.0',
);
final response = await repository.requestPhoneLoginLink('010-1234-5678');
expect(response.pendingRef, 'pending-ref');
});
test('stores session when login link polling completes', () async {
final repository = RemoteAuthRepository(
apiClient: AuthApiClient(
httpClient: MockClient((request) async {
expect(request.url.path, '/api/v1/auth/link/poll');
return http.Response(
jsonEncode({
'status': 'ok',
'accessToken': 'session-token',
'expiresAt': '2026-07-02T12:00:00Z',
'user': {
'id': 'user-uuid',
'name': 'User One',
'phoneNumber': '+821012345678',
'tenantId': 'tenant-uuid',
'tenantName': 'Hanmac',
'tenantSlug': 'hanmac',
},
}),
200,
);
}),
baseUri: Uri.parse('https://sso.example.test'),
),
sessionStore: const AuthSessionStore(),
appVersion: '0.1.0',
);
final response = await repository.pollPhoneLoginLink('pending-ref');
final session = await repository.loadSession();
expect(response.session?.token, 'session-token');
expect(session?.token, 'session-token');
});
}
@@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('saves loads and clears login session', () async {
const store = AuthSessionStore();
final response = PhoneLoginResponse(
status: 'ok',
token: 'session-token',
expiresAt: DateTime.parse('2026-07-02T12:00:00Z'),
user: const LoginUser(
id: 'user-uuid',
name: 'User One',
phoneNumber: '+821012345678',
tenantId: 'tenant-uuid',
tenantName: 'Hanmac',
tenantSlug: 'hanmac',
),
orgContextCredential: OrgContextCredential(
baseUrl: 'https://sadmin.hmac.kr',
tenantSlug: 'hanmac-family',
keyId: 'session-key-id',
keySecret: 'session-key-secret',
expiresAt: DateTime.parse('2099-07-02T13:00:00Z'),
),
);
await store.save(response);
final loaded = await store.load();
expect(loaded?.token, 'session-token');
expect(loaded?.user.name, 'User One');
expect(loaded?.orgContextCredential?.keyId, 'session-key-id');
expect(loaded?.orgContextCredential?.keySecret, 'session-key-secret');
await store.clear();
expect(await store.load(), isNull);
});
}
@@ -0,0 +1,18 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tdc114plus/src/core/actions/contact_launcher.dart';
void main() {
test('buildCallUri normalizes separators and keeps country code', () {
final uri = buildCallUri('+82 10-1234-5678');
expect(uri, isNotNull);
expect(uri.toString(), 'tel:+821012345678');
});
test('buildSmsUri returns null for empty phone number', () {
final uri = buildSmsUri(' - ');
expect(uri, isNull);
});
}
@@ -0,0 +1,72 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
import 'package:tdc114plus/src/features/directory/data/directory_api_client.dart';
void main() {
setUp(() async {
SharedPreferences.setMockInitialValues({});
await const AuthSessionStore().save(
PhoneLoginResponse(
status: 'ok',
token: 'session-token',
expiresAt: DateTime.parse('2026-07-02T12:00:00Z'),
user: const LoginUser(
id: 'user-uuid',
name: 'User One',
phoneNumber: '+821012345678',
tenantId: 'tenant-uuid',
tenantName: 'Hanmac',
tenantSlug: 'hanmac',
),
),
);
});
test('lists employees with bearer token and query filters', () async {
final client = DirectoryApiClient(
httpClient: MockClient((request) async {
expect(request.headers['authorization'], 'Bearer session-token');
expect(request.url.path, '/api/v1/tdc114plus/directory/employees');
expect(request.url.queryParameters['q'], 'kim');
expect(request.url.queryParameters['tenantSlug'], 'hanmac');
return http.Response(
jsonEncode({
'items': [
{
'id': 'user-uuid',
'name': 'User One',
'phoneNumber': '+821012345678',
'phoneDisplay': '010-1234-5678',
'tenantId': 'tenant-uuid',
'tenantName': 'Hanmac',
'tenantSlug': 'hanmac',
'status': 'active',
},
],
'limit': 50,
'offset': 0,
'total': 1,
'nextCursor': '',
}),
200,
);
}),
baseUri: Uri.parse('https://sso.example.test'),
sessionStore: const AuthSessionStore(),
);
final response = await client.listEmployees(
query: 'kim',
tenantSlug: 'hanmac',
);
expect(response.total, 1);
expect(response.items.single.phoneDisplay, '010-1234-5678');
});
}
@@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tdc114plus/src/features/directory/presentation/directory_navigation.dart';
import 'package:tdc114plus/src/features/organization/domain/organization_models.dart';
void main() {
const tenants = [
TenantSummary(
id: 'company-a',
name: '총괄기획실/기술',
slug: 'hq-tech',
type: 'COMPANY',
memberCount: 10,
totalMemberCount: 50,
),
TenantSummary(
id: 'company-b',
name: '바른컨설턴트',
slug: 'consulting',
type: 'COMPANY',
memberCount: 5,
totalMemberCount: 20,
),
TenantSummary(
id: 'team-is3',
name: 'IS3',
slug: 'is3',
type: 'ORGANIZATION',
parentId: 'company-a',
memberCount: 3,
totalMemberCount: 3,
),
TenantSummary(
id: 'team-ptc',
name: 'PTC',
slug: 'ptc',
type: 'ORGANIZATION',
parentId: 'company-a',
memberCount: 2,
totalMemberCount: 2,
),
];
test('shows pinned team chip for selected company', () {
final layout = buildTenantNavigationModel(
tenants: tenants,
selectedTenantSlug: 'hq-tech',
pinnedTenantSlug: 'is3',
);
expect(layout.companyTenants.map((tenant) => tenant.slug), [
'hq-tech',
'consulting',
]);
expect(layout.visibleChips.map((tenant) => tenant.slug), [
'hq-tech',
'consulting',
'is3',
]);
});
test('builds breadcrumb and child tenants for selected child team', () {
final layout = buildTenantNavigationModel(
tenants: tenants,
selectedTenantSlug: 'is3',
pinnedTenantSlug: 'is3',
);
expect(layout.selectedTenant?.slug, 'is3');
expect(layout.breadcrumbTenants.map((tenant) => tenant.slug), [
'hq-tech',
'is3',
]);
expect(layout.childTenants, isEmpty);
});
test('shows company drilldown for all selection', () {
final layout = buildTenantNavigationModel(
tenants: tenants,
selectedTenantSlug: 'all',
pinnedTenantSlug: 'is3',
);
expect(layout.selectedTenant, isNull);
expect(layout.childTenants.map((tenant) => tenant.slug), [
'hq-tech',
'consulting',
]);
expect(layout.visibleChips.map((tenant) => tenant.slug), [
'hq-tech',
'consulting',
'is3',
]);
});
test('builds selected tenant subtree slugs for scoped search', () {
final layout = buildTenantNavigationModel(
tenants: tenants,
selectedTenantSlug: 'hq-tech',
pinnedTenantSlug: 'is3',
);
expect(layout.selectedTenantSubtreeSlugs, ['hq-tech', 'is3', 'ptc']);
});
test('resolves pinned team slug from company and department name', () {
final slug = resolvePinnedTenantSlug(
tenants: tenants,
companySlug: 'hq-tech',
departmentName: 'IS3',
);
expect(slug, 'is3');
});
}
@@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tdc114plus/src/features/directory/data/directory_repository.dart';
import 'package:tdc114plus/src/features/directory/domain/employee.dart';
void main() {
test('DirectoryQuery maps all tenant filter to nullable API parameters', () {
const query = DirectoryQuery(query: ' 김하늘 ', tenantSlug: 'all');
expect(query.apiQuery, '김하늘');
expect(query.apiTenantSlug, isNull);
});
test('DirectoryQuery keeps selected tenant slug for API requests', () {
const query = DirectoryQuery(query: '', tenantSlug: 'hanmac');
expect(query.apiQuery, isNull);
expect(query.apiTenantSlug, 'hanmac');
});
test('DirectoryRepository now focuses on employee lists only', () {
const employees = [
Employee(
id: 'user-001',
name: '김하늘',
phoneNumber: '+821012345678',
tenantId: 'tenant-hanmac',
tenantName: '한맥',
tenantSlug: 'hanmac',
),
];
expect(employees.single.name, '김하늘');
expect(employees.single.tenantSlug, 'hanmac');
});
}
@@ -0,0 +1,297 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
import 'package:tdc114plus/src/features/directory/data/profile_image_api_client.dart';
import 'package:tdc114plus/src/features/directory/domain/employee.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('keeps explicit profileImageUrl without auth lookup', () async {
var requestCount = 0;
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
requestCount += 1;
return http.Response('{}', 500);
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: const AuthSessionStore(),
);
const employee = Employee(
id: 'user-1',
name: '강경훈',
phoneNumber: '01012345678',
email: 'khkang@samaneng.com',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
profileImageUrl: 'https://cdn.example.test/khkang.jpg',
);
final imageUrl = await client.resolveImageUrl(employee);
expect(imageUrl, 'https://cdn.example.test/khkang.jpg');
expect(requestCount, 0);
});
test('uses auth profile-image endpoint when explicit profile image is absent', () async {
const store = AuthSessionStore();
await store.save(
PhoneLoginResponse(
status: 'ok',
token: 'app-session-token',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
user: const LoginUser(
id: 'user-1',
name: '강경훈',
phoneNumber: '01012345678',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
),
),
);
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
expect(request.url.host, '114-auth.hmac.kr');
expect(request.url.path, '/api/v1/profile-image');
expect(request.url.queryParameters['comp'], 'SAMAN');
expect(request.url.queryParameters['email'], 'khkang@samaneng.com');
expect(request.headers['Authorization'], 'Bearer app-session-token');
return http.Response(
jsonEncode({
'found': true,
'source': 'BARON_UUID_R2',
'imageUrl':
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
}),
200,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: store,
);
const employee = Employee(
id: 'bebb832c-052e-493d-b5a9-732518d67685',
name: '강경훈',
phoneNumber: '01012345678',
email: 'khkang@samaneng.com',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
);
final imageUrl = await client.resolveImageUrl(employee);
expect(
imageUrl,
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
);
});
test('returns null when employee email is missing', () async {
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
fail('auth lookup should not be sent without email');
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: const AuthSessionStore(),
);
const employee = Employee(
id: 'user-2',
name: '직원',
phoneNumber: '01000000000',
tenantId: 'tenant-2',
tenantName: '한맥',
tenantSlug: 'hanmac',
);
expect(await client.resolveImageUrl(employee), isNull);
});
test('falls back to public uuid image when employee email is missing', () async {
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
fail('auth lookup should not be sent without email');
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: const AuthSessionStore(),
);
const employee = Employee(
id: 'bebb832c-052e-493d-b5a9-732518d67685',
name: '강경훈',
phoneNumber: '01012345678',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
);
expect(
await client.resolveImageUrl(employee),
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
);
});
test('allows auth lookup without company code when tenant mapping is unknown', () async {
const store = AuthSessionStore();
await store.save(
PhoneLoginResponse(
status: 'ok',
token: 'app-session-token',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
user: const LoginUser(
id: 'user-3',
name: '직원',
phoneNumber: '01011112222',
tenantId: 'tenant-3',
tenantName: 'Unknown',
tenantSlug: 'unknown',
),
),
);
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
expect(request.url.queryParameters['comp'], isNull);
expect(request.url.queryParameters['email'], 'person@example.com');
return http.Response(
jsonEncode({'found': false, 'source': 'DEFAULT'}),
200,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: store,
);
const employee = Employee(
id: 'user-3',
name: '직원',
phoneNumber: '01011112222',
email: 'person@example.com',
tenantId: 'tenant-3',
tenantName: 'Unknown',
tenantSlug: 'unknown',
);
expect(await client.resolveImageUrl(employee), isNull);
});
test('falls back to public uuid image when auth lookup returns not found', () async {
const store = AuthSessionStore();
await store.save(
PhoneLoginResponse(
status: 'ok',
token: 'app-session-token',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
user: const LoginUser(
id: 'user-4',
name: '강경훈',
phoneNumber: '01012345678',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
),
),
);
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
return http.Response(
jsonEncode({'found': false, 'source': 'DEFAULT'}),
200,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: store,
);
const employee = Employee(
id: 'bebb832c-052e-493d-b5a9-732518d67685',
name: '강경훈',
phoneNumber: '01012345678',
email: 'khkang@samaneng.com',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
);
expect(
await client.resolveImageUrl(employee),
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
);
});
test('uses uuid fallback even when auth not-found result is cached', () async {
const store = AuthSessionStore();
await store.save(
PhoneLoginResponse(
status: 'ok',
token: 'app-session-token',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
user: const LoginUser(
id: 'user-5',
name: '직원',
phoneNumber: '01012345678',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
),
),
);
var requestCount = 0;
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
requestCount += 1;
return http.Response(
jsonEncode({'found': false, 'source': 'DEFAULT'}),
200,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: store,
);
const firstEmployee = Employee(
id: 'legacy-user-id',
name: '직원',
phoneNumber: '01012345678',
email: 'cached-null@example.com',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
);
const secondEmployee = Employee(
id: 'bebb832c-052e-493d-b5a9-732518d67685',
name: '직원',
phoneNumber: '01012345678',
email: 'cached-null@example.com',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
);
expect(await client.resolveImageUrl(firstEmployee), isNull);
expect(
await client.resolveImageUrl(secondEmployee),
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
);
expect(requestCount, 1);
});
}
@@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tdc114plus/src/features/favorites/data/favorites_repository.dart';
void main() {
test('loads empty favorites when storage is empty', () async {
final repository = SharedPreferencesFavoritesRepository.withStore(
_MemoryFavoritesStore(),
);
final favorites = await repository.loadFavorites();
expect(favorites, isEmpty);
});
test('toggles favorites in local storage', () async {
final repository = SharedPreferencesFavoritesRepository.withStore(
_MemoryFavoritesStore(),
);
await repository.toggleFavorite('user-001');
final added = await repository.loadFavorites();
expect(added.map((favorite) => favorite.employeeId), ['user-001']);
await repository.toggleFavorite('user-001');
final removed = await repository.loadFavorites();
expect(removed, isEmpty);
});
}
class _MemoryFavoritesStore implements FavoritesKeyValueStore {
final _values = <String, String>{};
@override
Future<String?> getString(String key) async {
return _values[key];
}
@override
Future<void> setString(String key, String value) async {
_values[key] = value;
}
}
+48
View File
@@ -50,6 +50,54 @@ void main() {
expect(response.user.tenantSlug, 'hanmac');
});
test('PhoneLoginLinkInitResponse parses pending auth response', () {
final response = PhoneLoginLinkInitResponse.fromJson({
'status': 'pending',
'pendingRef': 'pending-ref',
'expiresIn': 180,
'interval': 3,
'resendAfter': 30,
'provider': 'Ory (Kratos/Hydra)',
});
expect(response.pendingRef, 'pending-ref');
expect(response.interval, 3);
expect(response.provider, 'Ory (Kratos/Hydra)');
});
test('PhoneLoginLinkPollResponse parses completed session', () {
final response = PhoneLoginLinkPollResponse.fromJson({
'status': 'ok',
'session': {
'status': 'ok',
'token': 'baron-sso-session-token',
'expiresAt': '2026-07-02T12:00:00Z',
'user': {
'id': 'user-uuid',
'name': 'User One',
'phoneNumber': '+821012345678',
'tenantId': 'tenant-uuid',
'tenantName': 'Hanmac',
'tenantSlug': 'hanmac',
},
},
});
expect(response.session?.token, 'baron-sso-session-token');
expect(response.isPending, isFalse);
});
test('PhoneLoginLinkPollResponse detects pending response', () {
final response = PhoneLoginLinkPollResponse.fromJson({
'status': 'pending',
'code': 'authorization_pending',
'interval': 5,
});
expect(response.isPending, isTrue);
expect(response.interval, 5);
});
test('CurrentUser parses permissions and can map to employee', () {
final user = CurrentUser.fromJson({
'id': 'user-uuid',
@@ -0,0 +1,371 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:tdc114plus/src/features/directory/data/directory_repository.dart';
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
import 'package:tdc114plus/src/features/organization/data/org_context_api_client.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
OrgContextApiClient.clearCacheForTesting();
});
test('maps Baron org-context response to app org chart snapshot', () async {
final client = OrgContextApiClient(
httpClient: MockClient((request) async {
expect(request.url.path, '/api/v1/integrations/org-context');
expect(request.url.queryParameters['tenantSlug'], 'hanmac-family');
expect(request.url.queryParameters['includeUsers'], 'true');
expect(request.url.queryParameters['includeUserIds'], 'true');
expect(request.headers['X-Baron-Key-ID'], 'key-id');
expect(request.headers['X-Baron-Key-Secret'], 'key-secret');
return _jsonResponse(_orgContextResponse());
}),
baseUri: Uri.parse('https://sadmin.hmac.kr'),
keyId: 'key-id',
keySecret: 'key-secret',
tenantSlug: 'hanmac-family',
);
final snapshot = await client.fetchOrgContext();
expect(snapshot.tenants.map((tenant) => tenant.slug), contains('is3'));
expect(snapshot.employees, hasLength(4));
expect(snapshot.employees.first.tenantName, 'IS3');
expect(snapshot.employees.first.phoneDisplay, '010-3189-1514');
final center = snapshot.tenants.singleWhere(
(tenant) => tenant.slug == 'center',
);
final is3 = snapshot.tenants.singleWhere((tenant) => tenant.slug == 'is3');
expect(center.totalMemberCount, 4);
expect(is3.memberCount, 2);
expect(is3.totalMemberCount, 2);
});
test('uses session org-context credential before env fallback', () async {
const store = AuthSessionStore();
await store.save(
PhoneLoginResponse(
status: 'ok',
token: 'session-token',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
user: const LoginUser(
id: 'user-id',
name: 'User',
phoneNumber: '',
tenantId: '',
tenantName: '',
tenantSlug: '',
),
orgContextCredential: const OrgContextCredential(
baseUrl: 'https://session.example.test',
tenantSlug: 'session-family',
keyId: 'session-key-id',
keySecret: 'session-key-secret',
),
),
);
final client = OrgContextApiClient(
httpClient: MockClient((request) async {
expect(request.url.host, 'session.example.test');
expect(request.url.queryParameters['tenantSlug'], 'session-family');
expect(request.headers['Authorization'], 'Bearer session-token');
expect(request.headers['X-Baron-Key-ID'], 'session-key-id');
expect(request.headers['X-Baron-Key-Secret'], 'session-key-secret');
return _jsonResponse(_orgContextResponse());
}),
baseUri: Uri.parse('https://env.example.test'),
keyId: 'env-key-id',
keySecret: 'env-key-secret',
tenantSlug: 'env-family',
sessionStore: store,
);
await client.fetchOrgContext();
});
test('uses requested tenant slug for scoped org-context reads', () async {
final client = OrgContextApiClient(
httpClient: MockClient((request) async {
expect(request.url.queryParameters['tenantSlug'], 'is3');
return _jsonResponse(_orgContextResponse());
}),
baseUri: Uri.parse('https://sadmin.hmac.kr'),
keyId: 'key-id',
keySecret: 'key-secret',
tenantSlug: 'hanmac-family',
);
await client.fetchOrgContext(tenantSlug: 'is3');
});
test('caches org-context response by requested tenant slug', () async {
final requestedTenantSlugs = <String>[];
final client = OrgContextApiClient(
httpClient: MockClient((request) async {
requestedTenantSlugs.add(request.url.queryParameters['tenantSlug']!);
return _jsonResponse(_orgContextResponse());
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
keyId: '',
keySecret: '',
tenantSlug: 'hanmac-family',
);
await client.fetchOrgContext(tenantSlug: 'is3');
await client.fetchOrgContext(tenantSlug: 'is3');
await client.fetchOrgContext(tenantSlug: 'samahn');
expect(requestedTenantSlugs, ['is3', 'samahn']);
});
test('uses app session bearer token for auth-server org-context proxy', () async {
const store = AuthSessionStore();
await store.save(
PhoneLoginResponse(
status: 'ok',
token: 'app-session-token',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
user: const LoginUser(
id: 'user-id',
name: 'User',
phoneNumber: '',
tenantId: '',
tenantName: '',
tenantSlug: '',
),
),
);
final client = OrgContextApiClient(
httpClient: MockClient((request) async {
expect(request.url.host, '114-auth.hmac.kr');
expect(request.url.path, '/api/v1/integrations/org-context');
expect(request.headers['Authorization'], 'Bearer app-session-token');
expect(request.headers.containsKey('X-Baron-Key-ID'), isFalse);
expect(request.headers.containsKey('X-Baron-Key-Secret'), isFalse);
return _jsonResponse(_orgContextResponse());
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
keyId: '',
keySecret: '',
tenantSlug: 'hanmac-family',
sessionStore: store,
);
await client.fetchOrgContext();
});
test('caches org-context response for repeated badge navigation reads', () async {
var requestCount = 0;
final client = OrgContextApiClient(
httpClient: MockClient((request) async {
requestCount += 1;
expect(request.url.path, '/api/v1/integrations/org-context');
return _jsonResponse(_orgContextResponse());
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
keyId: '',
keySecret: '',
tenantSlug: 'hanmac-family',
);
await client.fetchOrgContext();
await client.fetchOrgContext();
expect(requestCount, 1);
});
test(
'falls back to env org-context credential without session credential',
() async {
final client = OrgContextApiClient(
httpClient: MockClient((request) async {
expect(request.url.host, 'env.example.test');
expect(request.url.queryParameters['tenantSlug'], 'env-family');
expect(request.headers['X-Baron-Key-ID'], 'env-key-id');
expect(request.headers['X-Baron-Key-Secret'], 'env-key-secret');
return _jsonResponse(_orgContextResponse());
}),
baseUri: Uri.parse('https://env.example.test'),
keyId: 'env-key-id',
keySecret: 'env-key-secret',
tenantSlug: 'env-family',
sessionStore: const AuthSessionStore(),
);
await client.fetchOrgContext();
},
);
test(
'remote directory repository filters org-context employees locally',
() async {
final client = OrgContextApiClient(
httpClient: MockClient((request) async {
expect(request.url.queryParameters['tenantSlug'], 'is3');
return _jsonResponse(_orgContextResponse());
}),
baseUri: Uri.parse('https://sadmin.hmac.kr'),
keyId: 'key-id',
keySecret: 'key-secret',
tenantSlug: 'hanmac-family',
);
final repository = RemoteDirectoryRepository(orgContextApiClient: client);
final employees = await repository.loadEmployees(
const DirectoryQuery(query: '', tenantSlug: 'is3'),
);
expect(employees.map((employee) => employee.name), ['한승민']);
},
);
test(
'remote directory repository searches selected tenant subtree',
() async {
final client = OrgContextApiClient(
httpClient: MockClient((request) async {
expect(request.url.queryParameters['tenantSlug'], 'center');
return _jsonResponse(_orgContextResponse());
}),
baseUri: Uri.parse('https://sadmin.hmac.kr'),
keyId: 'key-id',
keySecret: 'key-secret',
tenantSlug: 'hanmac-family',
);
final repository = RemoteDirectoryRepository(orgContextApiClient: client);
final employees = await repository.loadEmployees(
const DirectoryQuery(
query: '',
tenantSlug: 'center',
tenantSlugs: ['center', 'is3', 'is2'],
),
);
expect(employees.map((employee) => employee.name), ['박주한']);
},
);
test(
'remote directory repository preserves department memberships for duplicate users',
() async {
final client = OrgContextApiClient(
httpClient: MockClient((_) async {
return _jsonResponse(_orgContextResponse());
}),
baseUri: Uri.parse('https://sadmin.hmac.kr'),
keyId: 'key-id',
keySecret: 'key-secret',
tenantSlug: 'hanmac-family',
);
final repository = RemoteDirectoryRepository(orgContextApiClient: client);
final employees = await repository.loadEmployees(
const DirectoryQuery(query: '', tenantSlug: 'all', department: 'IS3'),
);
expect(employees.map((employee) => employee.name), ['한승민', '김윤재']);
},
);
}
Map<String, dynamic> _orgContextResponse() {
return {
'schemaVersion': 'baron.org-context.v1',
'issuedAt': '2026-07-08T10:07:07.182Z',
'scope': {'tenantId': 'family-id', 'tenantSlug': 'hanmac-family'},
'tree': {
'id': 'family-id',
'type': 'COMPANY_GROUP',
'name': '한맥가족',
'slug': 'hanmac-family',
'memberCount': 0,
'children': [
{
'id': 'center-id',
'type': 'DEPARTMENT',
'name': '총괄기획&기술개발센터',
'slug': 'center',
'parentId': 'family-id',
'memberCount': 0,
},
],
},
'tenants': [
{
'id': 'is3-id',
'type': 'TEAM',
'name': 'IS3',
'slug': 'is3',
'parentId': 'center-id',
'memberCount': 0,
'members': [
{
'id': 'user-han',
'name': '한승민',
'phone': '+821031891514',
'email': 'han@example.com',
'department': 'IS3',
'position': '팀장',
'status': 'active',
},
{
'id': 'user-kim',
'name': '김윤재',
'phone': '01097479838',
'email': 'kim@example.com',
'department': 'IS3',
'position': '연구원',
'status': 'active',
},
],
},
{
'id': 'is2-id',
'type': 'TEAM',
'name': 'IS2',
'slug': 'is2',
'parentId': 'center-id',
'memberCount': 0,
'members': [
{
'id': 'user-park',
'name': '박주한',
'phone': '01089553850',
'email': 'park@example.com',
'department': 'IS2',
'position': '연구원',
'status': 'active',
},
{
'id': 'user-han',
'name': '한승민',
'phone': '01031891514',
'email': 'han@example.com',
'department': 'IS2',
'position': '팀장',
'status': 'active',
},
],
},
],
};
}
http.Response _jsonResponse(Map<String, dynamic> body) {
return http.Response.bytes(
utf8.encode(jsonEncode(body)),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
import 'package:tdc114plus/src/features/organization/data/organization_api_client.dart';
void main() {
setUp(() async {
SharedPreferences.setMockInitialValues({});
await const AuthSessionStore().save(
PhoneLoginResponse(
status: 'ok',
token: 'session-token',
expiresAt: DateTime.parse('2026-07-02T12:00:00Z'),
user: const LoginUser(
id: 'user-uuid',
name: 'User One',
phoneNumber: '+821012345678',
tenantId: 'tenant-uuid',
tenantName: 'Hanmac',
tenantSlug: 'hanmac',
),
),
);
});
test('loads org chart with bearer token', () async {
final client = OrganizationApiClient(
httpClient: MockClient((request) async {
expect(request.headers['authorization'], 'Bearer session-token');
expect(request.url.path, '/api/v1/tdc114plus/organization/orgchart');
return http.Response(
jsonEncode({
'tenants': [
{
'id': 'tenant-uuid',
'name': 'Hanmac',
'slug': 'hanmac',
'type': 'COMPANY',
'memberCount': 1,
'totalMemberCount': 1,
},
],
'employees': [
{
'id': 'user-uuid',
'name': 'User One',
'phoneNumber': '+821012345678',
'tenantId': 'tenant-uuid',
'tenantName': 'Hanmac',
'tenantSlug': 'hanmac',
},
],
'generatedAt': '2026-07-02T12:00:00Z',
'cache': {'source': 'db', 'hit': false, 'ttlSeconds': 0},
}),
200,
);
}),
baseUri: Uri.parse('https://sso.example.test'),
sessionStore: const AuthSessionStore(),
);
final snapshot = await client.getOrgChart();
expect(snapshot.tenants.single.slug, 'hanmac');
expect(snapshot.employees.single.name, 'User One');
});
}
+1515 -13
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
import 'package:integration_test/integration_test_driver.dart';
Future<void> main() => integrationDriver();
@@ -0,0 +1,229 @@
# tdc114plus API 계약
작성일: 2026-07-02
최종 개정일: 2026-07-10
상태: v3.3
목적: `tdc114plus` Flutter 앱이 공식 인터페이스 기준으로 설계·개발될 수 있도록 1차 API 계약 원칙과 우선 사용 흐름을 정리한다.
상위 기준 문서:
- `docs/00_policy_tdc114plus_decoupled_api_migration_2026-07-07.md`
- `docs/00_policy_tdc114plus_development_2026-07-02.md`
공식 인터페이스 기준:
- Swagger/API Docs: `https://sadmin.hmac.kr/api/docs#/`
- 사용자 확인 추가 기준: `tdc114plus 앱 전용 API는 없고`, Swagger에 공개된 Baron API를 앱이 직접 소비한다.
## 1. 전제
- `tdc114plus` 신규 앱은 Baron SSO에 등록되는 별도 `RP(Relying Party)`다.
- 앱 인증은 Baron SSO의 RP 로그인 절차 일부로 본다.
- 앱은 Baron SSO 내부 구현이 아니라 공식 API 계약을 기준으로 설계한다.
- 실제 backend 구현 상태와 무관하게 Flutter 앱은 이 계약을 바탕으로 mock/real 병행 개발이 가능해야 한다.
- 현재 저장소 코드에 남아 있는 `/api/v1/tdc114plus/...` 가정은 확정 계약이 아니라 재검증 대상이다.
- 재검증 대상 경로는 기능이 유지되는지 확인하면서 단계적으로 교체 또는 제거한다.
## 2. 핵심 원칙
- 신규 앱의 기본 로그인은 `Baron SSO Hosted Login + OIDC Authorization Code + PKCE`로 진행한다.
- 앱은 Baron SSO 인증 URL을 열고, Baron SSO 로그인 화면에서 휴대폰번호 입력 및 문자/메일 링크 인증을 처리한다.
- 앱은 callback으로 받은 authorization code를 PKCE `code_verifier`로 token 교환한 뒤 앱 세션을 저장한다.
- 로그인 성공 후 Baron SSO가 조직도 API 호출에 필요한 연동 키 묶음을 내려주는 구조를 최종 목표로 둔다.
- 해당 Baron SSO 기능이 개발되기 전까지는 앱 개발/검증용으로 로컬 비추적 환경값에 설정한 staging `org-context` 키를 fallback으로 사용한다.
- 기존 `선진행 후확인` 방식은 신규 앱 기본 인증 정책으로 사용하지 않는다.
- 기존 `phone-login` 방식은 개발용 fallback 또는 제한적 호환 범위로만 둔다.
- 레거시 계약 제거는 `대체 path 반영 -> mock/real 테스트 통과 -> 실제 화면 확인 -> 제거` 순서를 지킨다.
- JSON field는 camelCase를 사용한다.
- 목록 응답은 가능하면 `items`, `limit`, `offset`, `total`, `nextCursor` 형식을 따른다.
- UI는 DTO와 repository interface만 의존하고, endpoint path나 header를 직접 다루지 않는다.
## 3. 1차 범위
- Baron SSO Hosted Login 시작
- App Link callback 수신
- PKCE authorization code token 교환
- 로그인 세션 저장 후 앱 진입
- 직원검색
- 가족사/조직 탐색
- 조직도
- 직원 상세
- 즐겨찾기 로컬 저장
보류:
- 공지사항
- 전자결재
- 수신전화식별
- 수신팝업
- 서버 기반 즐겨찾기 동기화
## 4. 인증 계약
주의:
- `tdc114plus 앱 전용 API는 없다`는 사용자 확인이 들어왔으므로, 아래 계약은 Baron Swagger 기준으로 재정렬한다.
- Flutter 앱은 Baron headless API를 직접 호출하지 않는다.
- 휴대폰번호 입력과 링크 발송/승인은 Baron SSO Hosted Login 화면과 서버 내부 구현으로 둔다.
- legacy `/api/v1/tdc114plus/...` path는 예외 호환 또는 과거 흔적으로만 취급한다.
### 4.1 로그인 시작
```http
GET https://sso.hmac.kr/oidc/oauth2/auth
```
의미:
- 앱이 PKCE `code_verifier`, `code_challenge`, `state`, `nonce`를 생성한다.
- 앱이 Baron SSO authorization endpoint를 브라우저/커스텀탭으로 연다.
- 사용자는 Baron SSO Hosted Login 화면에서 휴대폰번호 입력과 문자/메일 링크 인증을 진행한다.
예시 query:
```http
client_id=39d6190d-72f6-4a58-a84f-cdc5ece3e8af
redirect_uri=https://114.hmac.kr/auth/callback
response_type=code
scope=openid profile email tenants
state={random-state}
nonce={random-nonce}
code_challenge={S256-code-challenge}
code_challenge_method=S256
```
### 4.2 callback 수신
```http
GET https://114.hmac.kr/auth/callback?code={authorization-code}&state={state}
```
의미:
- `https://114.hmac.kr/auth/callback`은 Android App Link로 앱에 연결한다.
- 앱은 callback의 `state`가 저장된 PKCE transaction의 `state`와 같은지 검증한다.
- `state`가 다르면 token 교환을 중단한다.
### 4.3 token 교환
```http
POST https://sso.hmac.kr/oidc/oauth2/token
```
의미:
- 앱은 authorization code와 저장된 `code_verifier`를 사용해 token endpoint를 호출한다.
- PKCE 공개 앱이므로 Client Secret을 보내지 않는다.
예시 요청:
```http
grant_type=authorization_code
client_id=39d6190d-72f6-4a58-a84f-cdc5ece3e8af
code={authorization-code}
code_verifier={stored-code-verifier}
redirect_uri=https://114.hmac.kr/auth/callback
```
완료 응답 예시:
```json
{
"access_token": "access-token",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "id-token",
"org_context": {
"base_url": "https://sadmin.hmac.kr",
"tenant_slug": "hanmac-family",
"key_id": "org-context-key-id",
"key_secret": "org-context-key-secret",
"expires_at": "2026-07-10T12:00:00Z"
}
}
```
`org_context`는 향후 Baron SSO가 제공할 예정인 확장 필드 예시다. 현재 staging 서버 기능이 미개발이면 응답에 없을 수 있으며, 앱은 이 필드가 없을 때 로컬 비추적 환경값의 staging 고정 키를 사용한다.
### 4.4 headless API 직접 호출 제외
```http
POST /api/v1/auth/headless/phone-login
POST /api/v1/auth/headless/link/poll
```
- 위 API는 Baron SSO 내부 구현 또는 confidential client용 참고 계약으로 본다.
- 현재 Flutter 앱은 공개 PKCE 앱이므로 `client_assertion`, `private_key_jwt`, RP 개인키를 APK에 넣지 않는다.
- 따라서 위 API는 신규 앱 기본 로그인 구현에서 직접 호출하지 않는다.
### 4.5 레거시/개발용 즉시 로그인
```http
POST /api/v1/tdc114plus/auth/phone-login
```
- 이 경로는 개발용 fallback 또는 제한적 호환 범위로만 본다.
- 신규 앱 기본 로그인 UX로 간주하지 않는다.
## 5. 세션 및 사용자 정보
### 5.1 세션 저장 기준
- token 교환 성공 시 반환된 access token과 만료시간을 앱 저장소에 저장한다.
- 사용자 정보는 `id_token` claim 또는 후속 `userinfo` 응답에서 가져온다.
- 조직도 API 호출용 연동 키가 token 응답, userinfo, 또는 별도 session endpoint로 전달되면 앱 세션의 선택적 `orgContextCredential`로 저장한다.
- `orgContextCredential`이 있으면 `integrations/org-context` 호출 시 이 값을 우선 사용하고, 없으면 개발/검증용 비추적 환경값 fallback을 사용한다.
- 연동 키는 로그에 출력하지 않고, 운영 배포 전에는 안전 저장소 적용 여부를 별도 검토한다.
- 앱 재실행 시 저장 세션이 유효하면 로그인 화면을 건너뛴다.
- API 호출 중 `401/403`이 발생하면 세션 정리 후 재로그인 흐름으로 돌린다.
### 5.2 사용자 기본 정보
로그인 완료 후 앱이 기대하는 최소 사용자 정보:
```json
{
"id": "user-uuid",
"name": "홍길동",
"phoneNumber": "+821012345678",
"tenantId": "tenant-uuid",
"tenantName": "한맥",
"tenantSlug": "hanmac",
"department": "기술연구소",
"grade": "책임",
"position": "팀장",
"jobTitle": "개발"
}
```
## 6. 조직/직원 데이터 계약 원칙
- 직원검색과 조직도는 공식 인터페이스가 제공하는 조직/직원 endpoint를 기준으로 설계한다.
- 실제 배포 전까지 조직/직원 API 기준은 staging Swagger(`https://sadmin.hmac.kr/api/docs#/Integrations/get_api_v1_integrations_org_context`)와 staging `org-context` endpoint를 따른다.
- 가족사/조직 탐색은 `tenant` 계층과 `tenantSlug`를 기준으로 진행한다.
- 로그인 직후 기본 범위는 사용자의 회사급 `tenantSlug`를 우선 기준으로 한다.
- 화면 정책상 필요한 drilldown 구조는 repository 계층에서 정리하고 UI는 결과 모델만 소비한다.
- 현재 `/api/v1/tdc114plus/organization/*`, `/api/v1/tdc114plus/directory/*` 같은 path는 확정 계약이 아니라 placeholder 성격일 수 있으므로, Swagger의 실제 공개 path인 `integrations/org-context`, `public/orgchart` 기준으로 교체한다.
- 교체 전까지는 mock과 실제 화면이 같은 결과를 내는지 확인해 기능 공백 없이 전환한다.
### 6.1 subtree 계약 보강 필요사항
- 회사 선택 후 `부서 -> 팀 -> 개인` drilldown을 구현하려면 조직 subtree 조회 계약이 필요하다.
- 현재 API가 최상위 tenant 위주 응답만 제공하는 경우, 앱은 fallback으로 회사 단위 직원 목록과 본인팀 synthetic chip만 유지한다.
- Swagger의 `public/orgchart`처럼 `tenants[]`, `users[]` 중심 응답을 사용할 경우에도, 최종 목표 계약은 해당 배열만으로 특정 tenant 기준 자식 조직 subtree와 leaf 판단 정보를 복원할 수 있는 형태다.
- 해당 보강 요청은 `docs/00_guide_baron_sso_subtree_api_issue_request_2026-07-07.md` 초안 기준으로 Baron SSO 이슈로 병행 관리한다.
## 7. Flutter 구현 해석
- `auth` feature는 Hosted Login authorization URL 생성, PKCE transaction 저장, callback token 교환, 세션 저장 흐름을 우선 구현한다.
- `directory`, `organization` feature는 `org-context` 중심 Swagger DTO와 repository interface를 먼저 고정한다.
- mock repository는 실제 API 미구현 상태에서도 유지한다.
- 실제 API 경로와 mock 경로는 동일한 UI 흐름을 만족해야 한다.
## 8. 문서 갱신 원칙
- Swagger 기준이 달라지면 이 문서를 먼저 갱신한다.
- 새 endpoint를 사용하기 시작하면 request/response 예시를 추가한다.
- 내부 추정이 아니라 공식 인터페이스에서 확인된 내용만 확정 표현으로 남긴다.
- 사용자 확인으로 뒤집힌 가정은 즉시 `가정` 또는 `재검증 대상`으로 강등한다.
@@ -0,0 +1,60 @@
# Baron SSO PKCE 모바일 RP Headless 계약 확인 요청
## 이슈 제목
`[API Contract Request] PKCE 모바일 RP의 Headless 전화번호 로그인 지원 방식 확인 요청`
## 배경
신규 Flutter 앱 `TDC114PLUS`를 Baron SSO의 PKCE 공개 클라이언트 RP로 등록했습니다.
- Client ID: `39d6190d-72f6-4a58-a84f-cdc5ece3e8af`
- Redirect URI: `https://114.hmac.kr/auth/callback`
- Client Secret: 없음
- OIDC issuer: `https://sso.hmac.kr/oidc`
앱의 로그인 UX는 아래 순서를 사용합니다.
1. 사용자가 앱에서 전화번호로 로그인을 요청
2. Baron SSO가 문자 또는 메일로 승인 링크 발송
3. 사용자가 링크 클릭
4. 앱이 승인 상태를 확인
5. OIDC authorization code + PKCE로 앱 세션 생성
## 확인된 계약
Swagger의 아래 API는 `client_assertion`을 필수로 요구합니다.
- `POST /api/v1/auth/headless/phone-login`
- `POST /api/v1/auth/headless/link/poll`
`client_assertion``private_key_jwt`로 설명되어 있습니다. 그러나 TDC114PLUS는 Client Secret이나 개인키를 안전하게 보관할 수 없는 PKCE 공개 모바일 앱입니다.
## 문제점
모바일 APK에 `private_key_jwt` 서명용 개인키를 포함하면 추출될 수 있으므로 공개 클라이언트 보안 모델에 맞지 않습니다.
OIDC Discovery에서는 token endpoint 인증 방식 `none`과 PKCE S256을 지원하므로 authorization code 교환은 가능하지만, 그 앞의 headless API 호출 방법이 현재 계약만으로는 확정되지 않습니다.
## 요청 사항
PKCE 공개 모바일 RP에서 아래 값을 획득하고 headless 로그인을 진행하는 공식 절차를 명세해 주시기 바랍니다.
1. `login_challenge` 획득 절차
2. `client_assertion` 생략 가능 여부
3. 생략이 불가능한 경우 단기·1회성 assertion 발급 endpoint
4. assertion의 issuer, subject, audience, 만료시간, 서명 알고리즘
5. `phone-login -> link/poll -> redirectTo -> callback -> token` 전체 순서
6. 각 단계의 Request/Response 예시와 오류 코드
## 제안 가능한 방식
- 모바일 PKCE RP에 한해 headless API에서 assertion 대신 등록된 Client ID, PKCE transaction, 짧은 TTL의 서버 challenge를 검증
- Baron SSO가 모바일 RP용 단기·1회성 assertion을 발급
- confidential 중계 서버를 별도로 두고 해당 서버만 `private_key_jwt`를 생성
## 완료 기준
- 개인키를 APK에 저장하지 않고 headless API를 호출할 수 있음
- 승인 완료 후 `https://114.hmac.kr/auth/callback`으로 authorization code가 전달됨
- 앱이 PKCE verifier로 token endpoint에서 토큰을 교환할 수 있음
@@ -0,0 +1,96 @@
# Baron SSO 하위조직 Subtree API 보강 요청 초안
작성일: 2026-07-07
상태: draft
목적: Baron SSO 개발자에게 `tdc114plus` 신규 앱 기준 하위조직 subtree API 보강 필요사항을 Gitea 이슈 형식으로 전달하기 위한 등록용 초안이다. 현재 Swagger의 `GET /api/v1/public/orgchart?token=...` 형태를 참고해, 실제 수신 가능한 응답 구조 기준으로 요청 내용을 맞춘다.
## 1. 이슈 제목 초안
`[Feature Request] tdc114plus 직원검색/조직도용 public orgchart subtree 응답 보강 필요`
## 2. 이슈 본문 초안
```md
## 배경
신규 앱 `tdc114plus`는 Baron SSO에 추가되는 별도 RP(Relying Party)로 개발 중이며,
직원검색/조직도 화면은 정의된 API 인터페이스를 통해 데이터를 조회하는 구조로 진행하고 있습니다.
현재 Swagger상 공개 조직 조회 API는 아래 형태로 확인됩니다.
- `GET /api/v1/public/orgchart?token=...`
- 응답 구조: `tenants[]`, `users[]`, `sharedWith`
신규 앱에서는 이 응답을 기반으로 `전체 -> 회사 -> 부서 -> 팀 -> 개인` 단계 탐색이 가능해야 합니다.
## 문제점
현재 제공 중인 조직 관련 API 응답은 최상위 회사/법인 수준 정보 위주로 내려오고 있어,
특정 회사 하위의 부서/팀 subtree 구조를 충분히 구성할 수 없습니다.
실제 확인 결과:
- `GET /api/v1/tdc114plus/organization/tenants` 응답은 최상위 tenant 위주
- `GET /api/v1/tdc114plus/organization/orgchart`도 동일하게 하위 부서/팀 subtree 확인이 어려움
- `tenantId`를 지정해도 회사 하위 조직 subtree가 아니라 동일한 최상위 목록 중심 응답으로 확인됨
- 직원 상세의 joined tenant 정보도 회사급까지만 확인되어 세부 부서/팀 탐색 기준으로 사용하기 어렵습니다.
- `public/orgchart` 예시도 현재는 `tenants[]`, `users[]` 기본 구조만 보여서, 하위조직 경로 복원용 정보가 부족해 보입니다.
## 재현 절차
1. 신규 앱에서 직원검색/조직도 화면 진입
2. 상단 회사 뱃지 선택
3. 선택한 회사의 하위조직 탐색 시도
4. 하위 부서/팀 단위 트리 확장에 필요한 subtree 데이터가 부족하여 화면 구성이 제한됨
## 현재 동작
- 최상위 조직(회사/법인) 중심 데이터만 확인 가능
- 회사 하위 부서/팀 subtree를 안정적으로 구성하기 어려움
- leaf 조직 선택 후 해당 조직 기준 후속 조회 구현이 제한됨
## 기대 동작
- `public/orgchart` 계열 응답만으로도 특정 회사 또는 조직 기준 하위 subtree 전체를 복원할 수 있어야 합니다.
- 각 tenant 항목에서 상위 조직 관계와 leaf 여부를 확인할 수 있어야 합니다.
- 각 user 항목은 어떤 tenant/조직에 속하는지 연결 가능해야 합니다.
- 프론트는 이 응답만으로 `전체 -> 회사 -> 부서 -> 팀 -> 개인` 탐색과 해당 조직 사용자 표시를 구현할 수 있어야 합니다.
## 요청 사항
현재 Swagger에 노출된 `GET /api/v1/public/orgchart?token=...`를 기준으로,
조직 subtree 복원이 가능하도록 응답 보강이 필요합니다.
가능한 방향 예시:
- 기존 `public/orgchart` 응답에 조직 계층 복원용 필드 추가
- 또는
- `public/orgchart`와 유사한 응답 구조를 유지하면서 subtree 전용 API 추가
예를 들어 `tenants[]`에 아래 정보가 필요합니다.
- `id`
- `parentId`
- `slug`
- `name`
- `type` (`COMPANY`, `DEPARTMENT`, `TEAM` 등)
- `hasChildren` 또는 `isLeaf`
- `depth` 또는 경로 복원 가능 정보
예를 들어 `users[]`에는 아래 정보가 필요합니다.
- `id`
- `name`
- `position`
- `jobTitle`
- `companyCode`
- `tenantId` 또는 `tenantSlug`
- 필요 시 `department`
즉, 프론트가 `tenants[]``users[]`만으로 조직 계층 구성, leaf 조직 판별, 해당 조직 사용자 표시를 수행할 수 있어야 합니다.
## 검토 포인트
- `public/orgchart` 응답 확장으로 처리할지, subtree 전용 API를 분리할지
- `tenants[]`, `users[]` 기준으로 조직-사용자 연결 식별자 통일 가능 여부
- leaf 조직 판단 기준 제공 여부
- 공유 token 기반 조회에서 subtree 범위 제한을 어떻게 둘지
- 신규 앱 포함 타 RP에서 공통 활용 가능 여부
```
## 3. 작성 메모
- 현재 Flutter 앱은 subtree API 부재 구간에서 `본인팀 synthetic chip + 회사 단위 직원 목록` fallback으로 동작하도록 정리했다.
- 즉, 앱 개발은 병행 가능하지만, 완전한 조직 drilldown UX는 해당 응답 보강이 필요하다.
- 현재 초안은 `신규 endpoint 생성 요구`보다 `기존 public orgchart 스타일 응답 보강 요청`에 더 가깝다.
- 등록 시에는 이 문서의 `## 2. 이슈 본문 초안` 블록만 그대로 사용하면 된다.
@@ -0,0 +1,83 @@
# TDC114PLUS Android App Link 설정 가이드
작성일: 2026-07-09
상태: debug 공기계 callback 수신 검증 완료
## 1. 목적
Baron SSO가 인증 완료 후 아래 HTTPS 주소로 이동했을 때 Android의 TDC114PLUS 앱이 열리도록 설정한다.
```text
https://114.hmac.kr/auth/callback
```
## 2. 앱 설정
| 항목 | 값 |
| --- | --- |
| Android package | `kr.co.baron.tdc114plus` |
| scheme | `https` |
| host | `114.hmac.kr` |
| path | `/auth/callback` |
| App Link 자동 검증 | 사용 |
## 3. 서버 담당자 요청 사항
`docs/references/assetlinks.debug.json` 파일을 아래 공개 주소에 JSON 원문으로 배포한다.
```text
https://114.hmac.kr/.well-known/assetlinks.json
```
배포 조건:
- HTTP 상태코드 `200`
- 리다이렉트 없이 직접 응답
- `Content-Type: application/json`
- 로그인이나 쿠키 요구 없음
- 외부 네트워크와 테스트 공기계에서 접근 가능
## 4. 현재 테스트 APK 지문
```text
3A:2D:60:48:6F:E5:3A:84:0C:41:14:DC:3A:17:A4:3B:64:E7:DE:A5:10:4A:09:26:0E:DC:5F:49:88:44:71:76
```
이 지문은 2026-07-09에 빌드한 debug APK용이다. 운영 APK는 운영 서명 인증서의 SHA-256 지문을 같은 배열에 추가해야 한다.
## 5. 검증 순서
1. 서버에서 `assetlinks.json`을 배포한다.
2. 최신 APK를 공기계에 재설치한다.
3. Android가 도메인 소유권 검증을 완료하도록 잠시 기다린다.
4. 아래 주소를 공기계에서 연다.
```text
https://114.hmac.kr/auth/callback?code=test-code&state=test-state
```
5. 브라우저나 앱 선택창이 아니라 TDC114PLUS의 `로그인 결과 확인` 화면이 열리는지 확인한다.
6. 실제 RP Client ID를 앱에 주입한 후 headless 승인부터 OIDC callback까지 E2E를 수행한다.
## 6. 현재 확인 결과
- `http://172.16.8.198:8080/auth/callback`: 정상
- `https://114.hmac.kr/auth/callback`: 브라우저 응답 정상
- 최신 debug APK 공기계 설치: 정상
- App Link 테스트: 앱 선택창 표시
- `assetlinks.json` 임시 테스트 서버 배포: 정상
- 외부 HTTPS JSON 응답: HTTP 200, `application/json` 확인
- 구형 공기계 자동 연결 상태: `undefined`
- 공기계 테스트 기본 앱 지정 후 callback 실행: TDC114PLUS `.MainActivity` 직접 실행 성공
- callback 화면에서 `code=test-code` 수신 확인
- 판정: callback 수신 경로는 검증 완료. 실제 Client ID와 PKCE code 교환 구현이 남아 있음
## 7. RP Client ID
```text
39d6190d-72f6-4a58-a84f-cdc5ece3e8af
```
- Client ID는 공개 식별자이므로 debug APK 기본 설정에 포함한다.
- Client Secret은 APK에 포함하지 않는다.
- 환경별 Client ID 변경은 `TDC114_OIDC_CLIENT_ID` Dart define을 사용한다.
@@ -0,0 +1,374 @@
# TDC114PLUS / tdc114plus-auth / Baron SSO Redirect Flow
작성일: 2026-07-15
## 결론
현재 우리가 진행 중인 방식에서는 TDC114PLUS 앱 RP의 아래 callback은 로그인 완료에 필수 아님이다.
```text
https://114.hmac.kr/auth/callback
```
현재 필요한 것은 `tdc114plus-auth` 중계서버 RP에 등록한 redirect URI다.
```text
https://114-auth.hmac.kr/api/v1/auth/oidc/callback
```
개발 로컬에서는 아래 URI를 사용한다.
```text
http://172.16.8.198:5001/api/v1/auth/oidc/callback
http://127.0.0.1:5001/api/v1/auth/oidc/callback
```
다만 앱 소스에는 아직 예전/보조 구조인 아래 callback이 남아 있다.
```text
https://114.hmac.kr/auth/callback
```
이 값은 Hosted Login + PKCE 방식으로 앱이 Baron SSO에 직접 붙던 구조의 흔적이다.
## 역할 구분
### TDC114PLUS 앱
- 전화번호 입력 화면 제공
- `tdc114plus-auth`에 링크 발송 요청
- `pendingRef`를 받아 저장
- 계속 poll
- poll 성공 시 받은 앱 세션 token 저장
- 직원검색 화면 진입
### tdc114plus-auth
- 앱 대신 Baron SSO headless API 호출
- `client_assertion` 생성
- `login_challenge` 확보
- Baron SSO `link/init`, `link/poll` 호출
- poll 성공 후 `redirectTo` 추적
- consent 필요 시 자동 consent 처리
- authorization code 수신
- token exchange
- 앱용 session token 발급
- 직원/조직 API 중계
### Baron SSO
- 사용자/전화번호 검증
- SMS 링크 발송
- 사용자가 링크 클릭하면 승인 처리
- OIDC authorization code 발급
- token endpoint 제공
## 현재 실제 로그인 흐름
### 1. 앱 실행
```text
TDC114PLUS 앱
```
### 2. 사용자가 전화번호 입력 후 로그인 링크 보내기
로컬 개발 기준:
```http
POST http://127.0.0.1:5001/api/v1/auth/link/init
```
실서버/도메인 기준:
```http
POST https://114-auth.hmac.kr/api/v1/auth/link/init
```
요청 예:
```json
{
"loginId": "01091365338"
}
```
### 3. tdc114plus-auth가 Baron SSO OIDC authorization 시작
```http
GET https://sso.hmac.kr/oidc/oauth2/auth
```
주요 값:
```text
client_id=tdc114plus-auth RP client_id
redirect_uri=https://114-auth.hmac.kr/api/v1/auth/oidc/callback
response_type=code
scope=openid profile email tenants
state=...
```
### 4. Baron SSO가 login_challenge 발급
```http
302 Location: https://sso.hmac.kr/login?login_challenge=...
```
### 5. tdc114plus-auth가 Baron SSO headless link init 호출
```http
POST https://sso.hmac.kr/api/v1/auth/headless/link/init
```
요청 주요 값:
```json
{
"client_id": "tdc114plus-auth RP client_id",
"client_assertion": "tdc114plus-auth가 개인키로 서명한 JWT",
"loginId": "01091365338",
"login_challenge": "..."
}
```
### 6. Baron SSO 응답
```json
{
"status": "pending",
"pendingRef": "...",
"interval": 2
}
```
### 7. tdc114plus-auth가 앱에 pendingRef 반환
```json
{
"status": "pending",
"pendingRef": "...",
"pollInterval": 2
}
```
### 8. 앱이 poll 시작
```http
POST http://127.0.0.1:5001/api/v1/auth/link/poll
```
요청:
```json
{
"pendingRef": "..."
}
```
### 9. tdc114plus-auth가 Baron SSO poll 호출
```http
POST https://sso.hmac.kr/api/v1/auth/headless/link/poll
```
요청 주요 값:
```json
{
"client_id": "tdc114plus-auth RP client_id",
"client_assertion": "tdc114plus-auth가 개인키로 서명한 JWT",
"pendingRef": "..."
}
```
## 운영 점검 기준 추가
2026-07-20 기준 실제 점검에서 아래 사실을 확인했다.
- 앱에서 `link/init` 요청이 로컬 `tdc114plus-auth:5001`에 정상 도달할 수 있다.
- `pendingRef` 생성과 `link/poll` 반복도 서버 로그에서 확인할 수 있다.
- 실기기 `adb reverse tcp:5001 tcp:5001`까지 정상이어도, 실제 사용자 휴대폰에 문자 링크가 도착하지 않을 수 있다.
이 경우의 판단 기준은 아래와 같다.
1. `link/init` 로그가 찍히고 `pendingRef`가 생성되면 앱 -> auth broker 구간은 우선 정상이다.
2. 이후 `link/poll`이 계속 pending인데 사용자 휴대폰에 문자가 오지 않으면, 우선 Baron SSO 문자 발송 또는 SMS 연계 구간을 의심해야 한다.
3.`pendingRef 생성 성공``문자 실제 발송 성공`과 같은 뜻이 아니다.
### 10. 사용자가 SMS 링크 클릭
```http
GET https://sso.hmac.kr/ko/verify-cc/...
```
또는 내부적으로:
```http
POST https://sso.hmac.kr/api/v1/auth/magic-link/verify
```
### 11. Baron SSO는 브라우저에 승인 완료 화면 표시
```text
https://sso.hmac.kr/ko/verify-complete
```
여기서 앱으로 자동 이동하지 않는다. 현재 Baron SSO 정책상 정상이다.
### 12. 앱의 poll이 성공 감지
Baron SSO에서 `tdc114plus-auth`로 내려오는 값:
```json
{
"status": "ok",
"redirectTo": "https://sso.hmac.kr/oidc/oauth2/auth?..."
}
```
### 13. tdc114plus-auth가 redirectTo 추적
```http
GET https://sso.hmac.kr/oidc/oauth2/auth?...
```
### 14. consent가 필요하면 consent 처리
```http
GET https://sso.hmac.kr/api/v1/auth/consent?consent_challenge=...
```
이후:
```http
POST https://sso.hmac.kr/api/v1/auth/consent/accept
```
### 15. Baron SSO가 최종 callback으로 code 전달
```http
GET https://114-auth.hmac.kr/api/v1/auth/oidc/callback?code=...&state=...
```
여기가 현재 필요한 redirect URI다.
### 16. tdc114plus-auth가 token exchange
```http
POST https://sso.hmac.kr/oidc/oauth2/token
```
주요 값:
```text
grant_type=authorization_code
code=...
redirect_uri=https://114-auth.hmac.kr/api/v1/auth/oidc/callback
client_id=tdc114plus-auth RP client_id
client_assertion=...
```
### 17. Baron SSO token 응답
```json
{
"access_token": "...",
"id_token": "...",
"token_type": "Bearer",
"expires_in": 3600
}
```
### 18. tdc114plus-auth가 앱용 세션 발급
앱 poll 응답:
```json
{
"status": "ok",
"accessToken": "tdc114plus-auth 앱용 세션 JWT",
"user": {
"id": "...",
"name": "...",
"email": "..."
}
}
```
### 19. 앱이 직원검색/조직정보 호출
로컬 개발 기준:
```http
GET http://127.0.0.1:5001/api/v1/integrations/org-context
Authorization: Bearer JWT
```
실서버 기준:
```http
GET https://114-auth.hmac.kr/api/v1/integrations/org-context
Authorization: Bearer JWT
```
## 그럼 https://114.hmac.kr/auth/callback은 무엇인가
아래 구조에서 필요했던 URI다.
```text
앱이 Baron SSO에 직접 OIDC 로그인 요청
-> Baron SSO 로그인
-> Baron SSO가 앱 App Link로 code 전달
-> 앱이 직접 token exchange
```
그때 필요한 redirect URI가 아래 값이었다.
```text
https://114.hmac.kr/auth/callback
```
하지만 지금 구조는 바뀌었다.
```text
앱이 직접 Baron SSO OIDC callback을 받지 않음
앱은 tdc114plus-auth에만 요청함
OIDC callback은 tdc114plus-auth가 받음
```
그래서 현재 필수 callback은 아래 값이다.
```text
https://114-auth.hmac.kr/api/v1/auth/oidc/callback
```
## 정리 표
| 항목 | 현재 필요 여부 | 용도 |
| --- | --- | --- |
| `https://114.hmac.kr/auth/callback` | 필수 아님 | 예전 앱 직접 OIDC/PKCE App Link callback |
| `https://114-auth.hmac.kr/api/v1/auth/oidc/callback` | 필요 | `tdc114plus-auth`가 Baron SSO authorization code 받는 callback |
| `https://114-auth.hmac.kr/.well-known/jwks.json` | 필요 | Baron SSO가 `tdc114plus-auth` client_assertion 서명 검증 |
| `https://114-auth.hmac.kr/api/v1/auth/link/init` | 필요 | 앱이 중계서버에 링크 발송 요청 |
| `https://114-auth.hmac.kr/api/v1/auth/link/poll` | 필요 | 앱이 승인 상태 확인 |
## 현재 판단
Baron SSO 쪽에 TDC114PLUS 앱 RP를 계속 유지할 필요는 현재 흐름 기준으로 낮다.
정식 구조를 단순화하려면 `tdc114plus-auth` RP 하나만 남기는 방향이 맞다.
2026-07-15 작업으로 앱 코드 안의 `/auth/callback` 직접 수신 흔적은 제거했다.
제거한 항목:
- AndroidManifest의 `https://114.hmac.kr/auth/callback` App Link intent-filter
- Flutter GoRouter의 `/auth/callback` route
- 앱 직접 OIDC/PKCE callback 화면
- 앱 직접 OIDC/PKCE token exchange 코드
- startup의 Windows App Link 테스트 서버 강제 확인
남겨둘 수 있는 항목:
- 이 문서 안의 `https://114.hmac.kr/auth/callback` 언급은 과거 구조 설명과 혼선 방지를 위한 기록이다.
@@ -0,0 +1,204 @@
# tdc114plus 배포 구조 마이그레이션 표
작성일: 2026-07-19
상태: v1.2
목적: `baron-sso-tdc114plus-api` 의존을 줄이고, 최종적으로 `tdc114plus` + `tdc114plus-auth` + Baron SSO 원본 `staging/prod` 구조로 전환하기 위해 현재 남아 있는 기능을 `배포 필수`, `개발 중 임시`, `제거 가능`으로 분류한다.
관련 문서:
- `docs/00_guide_tdc114plus_work_progress_timetable_2026-07-02.md`
- `docs/00_guide_tdc114plus_auth_broker_redirect_flow_2026-07-15.md`
- `docs/00_policy_tdc114plus_auth_repo_2026-07-15.md`
- `docs/00_policy_tdc114plus_decoupled_api_migration_2026-07-07.md`
## 1. 목표 구조
최종 배포 목표 구조는 아래와 같다.
- 모바일 앱: `tdc114plus`
- 인증/중계 서버: `tdc114plus-auth`
- 인증/조직 원본: Baron SSO 원본 `staging` -> 안정화 후 `production`
제거 목표:
- 로컬 개발 전용 Baron worktree `baron-sso-tdc114plus-api`가 배포 시점의 필수 구성으로 남지 않도록 한다.
## 2. 현재 확인 기준
2026-07-19 당시 auth 서버에 실제 등록된 route는 아래와 같았다.
- `GET /health`
- `GET /.well-known/jwks.json`
- `GET /api/v1/auth/jwks.json`
- `POST /api/v1/auth/link/init`
- `POST /api/v1/auth/link/poll`
- `GET /api/v1/integrations/org-context`
같은 날짜 기준 추가 확인:
- 당시 실행 중이던 5001 서버는 `/api/v1/profile-image``404`를 반환했다.
- 당시 체크아웃된 `tdc114plus-auth` 코드에도 `/api/v1/profile-image` route 등록이 없었다.
- 따라서 프로필 사진 1순위/2순위/3순위 실기기 검증은 `profile-image` endpoint 복구 또는 대체 경로 확정 전에는 완료 처리할 수 없다.
2026-07-20 추가 확인:
- 실기기와 로컬 `tdc114plus-auth:5001` 연결 자체는 정상이다.
- `adb reverse tcp:5001 tcp:5001` 정상 확인했다.
- 앱에서 `link/init` 호출 후 `pendingRef` 생성과 `link/poll` 반복도 서버 로그로 확인했다.
- 따라서 같은 날짜 기준 로그인 end-to-end 막힘의 주 blocker는 `앱/로컬 auth broker 연결`이 아니라 `Baron SSO 문자 링크 실제 발송 또는 SMS 연계 구간`이다.
- `tdc114plus-auth``GET /api/v1/profile-image` route를 복구했고, 미인증 호출 기준 `401 unauthorized`가 반환되는 것을 확인했다. 즉 route 부재 `404` 상태는 해소됐다.
- 로그인 정상화 후 `NAVER_WORKS``BARON_UUID_R2` source가 서버 로그와 실기기 화면에서 확인됐다.
- 네이버웍스 원본 `302 Location` URL은 앱에서 직접 열 수 없는 경우가 있어, `tdc114plus-auth``GET /api/v1/profile-image/naver-photo` 프록시 route를 추가했다.
- 프록시 route는 네이버웍스 Access Token으로 실제 이미지 바이너리를 받아 앱에 `image/jpeg`로 내려준다.
## 2-A. 현재 사용 API 목록
2026-07-20 기준 신규앱과 `tdc114plus-auth`가 실제 작업 기준으로 삼는 API는 아래와 같이 분류한다.
| 구분 | Method | Path | 호출 주체 | 현재 판단 | 용도 |
| --- | --- | --- | --- | --- | --- |
| 앱 중계 로그인 시작 | `POST` | `/api/v1/auth/link/init` | `tdc114plus` -> `tdc114plus-auth` | 현재 사용 | 앱에서 전화번호 기준 로그인 링크 요청 |
| 앱 중계 로그인 확인 | `POST` | `/api/v1/auth/link/poll` | `tdc114plus` -> `tdc114plus-auth` | 현재 사용 | 문자 링크 승인 후 앱 세션 발급 확인 |
| 앱 세션 JWKS | `GET` | `/api/v1/auth/jwks.json` | Baron/RP 검증 또는 내부 검증 | 현재 사용 | 앱 세션/JWT 검증 키 제공 |
| 조직/직원 중계 | `GET` | `/api/v1/integrations/org-context` | `tdc114plus` -> `tdc114plus-auth` | 현재 핵심 사용 | 직원검색, 조직도, `members[].id` UUID 확보 |
| 프로필 사진 lookup | `GET` | `/api/v1/profile-image` | `tdc114plus` -> `tdc114plus-auth` | 현재 사용 | NAVER WORKS -> Baron UUID R2 -> DEFAULT 판단 |
| 네이버웍스 사진 프록시 | `GET` | `/api/v1/profile-image/naver-photo` | `tdc114plus` -> `tdc114plus-auth` | 현재 사용 | NAVER WORKS 원본 URL 직접 노출 없이 `image/jpeg` 제공 |
| 원본 조직/직원 API | `GET` | `https://sadmin.hmac.kr/api/v1/integrations/org-context` | `tdc114plus-auth` -> Baron SSO 원본 | 현재 핵심 원본 | 조직/직원 원본 데이터 조회, 앱에 key/secret 비노출 |
| Baron 링크 로그인 시작 원본 | `POST` | `https://sso.hmac.kr/api/v1/auth/headless/link/init` | `tdc114plus-auth` -> Baron SSO 원본 | 현재 사용 | 실제 문자 링크 생성/발송 요청 |
| Baron 링크 로그인 확인 원본 | `POST` | `https://sso.hmac.kr/api/v1/auth/headless/link/poll` | `tdc114plus-auth` -> Baron SSO 원본 | 현재 사용 | pendingRef 승인 상태 확인 |
| Baron 매직 링크 검증 | `POST` | `https://sso.hmac.kr/api/v1/auth/magic-link/verify` | 문자 링크/Baron SSO 원본 흐름 | 연동 참고 | 사용자가 문자 링크를 눌렀을 때 승인 처리 후보/참고 경로 |
| NAVER WORKS 사진 조회 | `GET` | `https://www.worksapis.com/v1.0/users/{userId}/photo` | `tdc114plus-auth` -> NAVER WORKS | 현재 사용 | 프로필 사진 1순위 조회, `302 Location` 및 프록시 바이너리 응답 기반 |
현재 기준에서 직접 사용하지 않는 API는 아래처럼 둔다.
| 구분 | Method | Path | 현재 판단 | 이유 |
| --- | --- | --- | --- | --- |
| 공개 조직도 | `GET` | `/api/v1/public/orgchart` | 참고 API | 공유 링크/공개 조회 성격이므로 신규앱 내부 기준 데이터는 `integrations/org-context`로 유지 |
| 레거시 직원목록 | `GET` | `/api/v1/tdc114plus/directory/employees` | 제거 대상 | `org-context` 기반 재구성으로 대체 |
| 레거시 직원상세 | `GET` | `/api/v1/tdc114plus/directory/employees/{employeeId}` | 제거 대상 | `org-context members[]` 또는 auth proxy 보강으로 대체 |
| 레거시 테넌트 목록 | `GET` | `/api/v1/tdc114plus/organization/tenants` | 제거 대상 | `org-context tenants/tree` 기준으로 대체 |
| 레거시 조직도 | `GET` | `/api/v1/tdc114plus/organization/orgchart` | 제거 대상 | `org-context tree` 기준으로 대체 |
| 레거시 전화번호 로그인 | `POST` | `/api/v1/tdc114plus/auth/phone-login` | 제거 대상 | 현재 기본 로그인은 `tdc114plus-auth link/init/link/poll` 중계 흐름 |
## 3. 기능 분류표
| 기능/자산 | 현재 위치 | 현재 용도 | 분류 | 목표 방향 | 비고 |
| --- | --- | --- | --- | --- | --- |
| 앱 UI/상태관리 | `tdc114plus` | 모바일 앱 본체 | 배포 필수 | 유지 | 최종 배포 직접 대상 |
| 링크 로그인 진입 `link/init` | `tdc114plus-auth` | 앱 로그인 시작 | 배포 필수 | 유지 | Baron SSO headless/link upstream 호출 |
| 링크 로그인 완료 `link/poll` | `tdc114plus-auth` | 앱 세션 발급 | 배포 필수 | 유지 | 2026-07-19 기준 user 메타데이터 확장 반영 완료 |
| 앱 세션 JWT 발급/검증 | `tdc114plus-auth` | 앱 인증 유지 | 배포 필수 | 유지 | 앱 전용 세션 계층 |
| JWKS 제공 | `tdc114plus-auth` | Baron RP/JWKS 연계 | 배포 필수 | 유지 | 도메인 기준 배포 필요 |
| org-context proxy | `tdc114plus-auth` | 앱이 Baron key 없이 조직도 조회 | 배포 필수 | 유지 | staging/prod 원본 의존 |
| Baron OIDC authorization / token / consent 흐름 | Baron SSO 원본 | 인증 원본 | 배포 필수 | Baron 원본 의존 | 로컬 worktree 필수 아님 |
| Baron headless `link/init`, `link/poll` | Baron SSO 원본 | 문자 링크 승인 원본 | 배포 필수 | Baron 원본 의존 | auth 서버가 소비 |
| `GET /api/v1/integrations/org-context` 원본 | Baron SSO 원본 | 조직/직원 원본 데이터 | 배포 필수 | Baron 원본 의존 | 앱은 auth proxy 또는 직접 staging 기준 확인 |
| `/api/v1/tdc114plus/directory/employees` | `baron-sso-tdc114plus-api` | 과거 직원검색 경로 | 제거 가능 | `org-context` 중심으로 교체 | 레거시 흔적 |
| `/api/v1/tdc114plus/organization/tenants` | `baron-sso-tdc114plus-api` | 과거 테넌트 목록 경로 | 제거 가능 | `org-context` 중심으로 교체 | 레거시 흔적 |
| `/api/v1/tdc114plus/organization/orgchart` | `baron-sso-tdc114plus-api` | 과거 조직도 경로 | 제거 가능 | `org-context` 중심으로 교체 | 레거시 흔적 |
| `/api/v1/tdc114plus/auth/phone-login` | `baron-sso-tdc114plus-api` | 과거 예외 로그인 seed | 제거 가능 | 기본 로그인 경로에서 제외 | 테스트용 예외 경로만 검토 |
| 로컬 Baron compose runtime | `baron-sso-tdc114plus-api` | 로컬 재현/비교 | 개발 중 임시 | staging/prod 기준 검증으로 축소 | 배포 필수 구성 아님 |
| 프로필 사진 `/api/v1/profile-image` | `tdc114plus-auth` | NAVER WORKS -> UUID -> DEFAULT | 배포 필수 | auth 서버에 유지 | 2026-07-20 route 복구 및 실기기 확인 |
| 네이버웍스 사진 프록시 `/api/v1/profile-image/naver-photo` | `tdc114plus-auth` | NAVER WORKS 이미지 바이너리 프록시 | 배포 필수 | auth 서버에 유지 | 원본 Location 직접 노출 방지 |
| PostgreSQL 기반 프로필 매핑안 | `tdc114plus-auth` 과거 검토 | 사진 fallback 대안 | 제거 가능 | 운영 기본안에서 제외 | 보류 대안 |
## 3-A. `baron-sso-tdc114plus-api` -> `tdc114plus-auth` 흡수 가능 기능 표
| 기능 | 현재 기준 | `tdc114plus-auth` 흡수 가능 여부 | 이유 | 현재 상태 |
| --- | --- | --- | --- | --- |
| 앱 로그인 시작/완료 | 이미 `tdc114plus-auth` 담당 | 완료 | 앱 전용 broker 책임과 일치 | 유지 |
| 앱 세션 JWT 발급/검증 | 이미 `tdc114plus-auth` 담당 | 완료 | 모바일 세션 경계는 auth 서버 책임 | 유지 |
| 로그인 user 메타데이터 반환 | `tdc114plus-auth` 담당 | 완료 | 앱이 초기 컨텍스트에 바로 사용 가능 | 2026-07-19 확장 반영 |
| Baron org-context key 은닉 proxy | 이미 `tdc114plus-auth` 담당 | 완료 | 앱에 key/secret 비노출 | 유지 |
| 직원/조직 초기 컨텍스트 계산 보조 | 일부 앱 fallback 혼재 | 가능 | auth 응답 메타데이터가 충분하면 앱 fallback 감소 | 후속 검증 필요 |
| 프로필 사진 lookup endpoint | `tdc114plus-auth` | 완료 | 사진 우선순위 정책은 auth 서버가 가장 자연스러움 | 2026-07-20 route 복구 및 실기기 확인 |
| 네이버웍스 사진 프록시 endpoint | `tdc114plus-auth` | 완료 | 네이버웍스 원본 URL을 앱이 직접 열 수 없는 케이스를 서버가 흡수 | 배포 필수 기능으로 유지 |
| `/api/v1/tdc114plus/directory/*` 레거시 API | `baron-sso-tdc114plus-api` | 불필요 | org-context 중심 구조와 중복 | 제거 대상 |
| `/api/v1/tdc114plus/organization/*` 레거시 API | `baron-sso-tdc114plus-api` | 불필요 | org-context 중심 구조와 중복 | 제거 대상 |
| 로컬 Baron compose runtime | `baron-sso-tdc114plus-api` | 흡수 대상 아님 | 개발용 재현 자산이지 auth 기능이 아님 | 축소 대상 |
| Baron 원본 로그인/consent/token 발급 | Baron SSO 원본 | 흡수 불가 | 공식 인증 원본이므로 대체 대상 아님 | staging/prod 의존 유지 |
## 3-B. 제거 마이그레이션 실행 표
아래 표는 `baron-sso-tdc114plus-api`에 남아 있는 흔적을 실제 작업 단위로 쪼갠 것이다.
| 구분 | 현재 남은 흔적 | 실제 사용 주체 | 목표 처리 | 선행 조건 | 최종 판단 |
| --- | --- | --- | --- | --- | --- |
| 로그인 진입 | `/api/v1/tdc114plus/auth/phone-login` | 앱 구버전/초기 테스트 흔적 | 신규앱 경로에서 제거 | 현재 앱이 `link/init`, `link/poll`만 쓰는지 재확인 | 제거 가능 |
| 직원검색 목록 | `/api/v1/tdc114plus/directory/employees` | 앱 일부 코드/테스트 흔적 | `org-context` 재구성 결과로 완전 대체 | 목록/검색/상세가 `org-context` 기준으로 동일 동작 | 제거 가능 |
| 직원 상세 | `/api/v1/tdc114plus/directory/employees/:id` | 앱 일부 코드 흔적 | `org-context members[]` 기반 또는 auth proxy 보강으로 대체 | 현재 상세 화면 필요 필드 표 확정 | 제거 가능 |
| 회사/테넌트 목록 | `/api/v1/tdc114plus/organization/tenants` | 앱 일부 코드/테스트 흔적 | `org-context tenants[]`로 대체 | 현재 tenant chip/초기 선택 로직 안정화 | 제거 가능 |
| 조직도 트리 | `/api/v1/tdc114plus/organization/orgchart` | 앱 일부 코드/테스트 흔적 | `org-context tree + tenants[]`로 대체 | 조직도 화면 회귀 검증 | 제거 가능 |
| 조직도 credential 은닉 | Baron 전용 key/secret 직접 처리 필요성 | 신규앱 | `tdc114plus-auth /api/v1/integrations/org-context` 유지 | app session + proxy 정상 유지 | auth에 유지 |
| 앱 세션 발급 | Baron 원본에 없는 앱 전용 세션 계층 | 신규앱 | `tdc114plus-auth` 유지 | 현재 JWT 검증 안정화 | auth에 유지 |
| 프로필 이미지 lookup | `/api/v1/profile-image` auth 구현 | 신규앱 | auth에 유지 | 1순위/2순위 실기기 확인 완료, DEFAULT 확대 검증 | auth에 유지 |
| 로컬 Baron runtime | worktree 기동/비교 환경 | 개발자 로컬 | 배포 필수 구성에서 제외 | staging/prod 기준 검증 루틴 확보 | 개발 전용 유지 |
## 3-C. 워크스페이스별 최종 역할 표
| 워크스페이스 | 오늘 기준 역할 | 배포 직접 관여 | 비고 |
| --- | --- | --- | --- |
| `tdc114plus` | 모바일 앱 UI, 상태관리, 사진 fallback, 직원검색/조직도 화면 | 예 | 최종 APK/앱 배포 대상 |
| `tdc114plus-auth` | 앱 전용 인증 broker, 앱 세션 JWT, org-context proxy, profile-image lookup/proxy | 예 | 신규앱 전용 중계서버 |
| `baron-sso-tdc114plus-api` | 과거 레거시 API 검증, 로컬 비교, 흔적 확인용 worktree | 아니오 | 제거/축소 대상 |
| Baron SSO staging/prod 원본 | 실제 OIDC, 문자 링크, org-context 원본 | 예 | 신규앱의 최종 외부 의존 원본 |
## 4. 바로 이어서 손봐야 하는 항목
### 4.1 `tdc114plus-auth`에서 유지하되 보강이 필요한 것
1. `link/poll` 응답의 사용자 메타데이터 확장
- 2026-07-19 기준 아래 항목을 응답과 앱 세션 JWT `user` 클레임에 함께 담도록 반영했다.
- `tenantSlug`
- `tenantId`
- `tenantName`
- `department`
- `grade`
- `position`
- `jobTitle`
- 남은 확인:
- 실기기 로그인 후 앱에서 이 값들이 실제로 저장/사용되는지 확인
- 실제 Baron upstream 응답마다 중첩 구조 차이가 없는지 샘플 확대 확인
2. `/api/v1/profile-image` route 상태 재정렬
- 2026-07-20 기준 `tdc114plus-auth`에 route를 복구했다.
- 미인증 호출 기준 `401 unauthorized`가 반환되어 route 등록은 확인했다.
- 네이버웍스 `302 Location` 원본 URL 직접 사용 시 `400 Authentication failed`가 발생할 수 있어 `/api/v1/profile-image/naver-photo` 프록시를 추가했다.
- 2026-07-20 기준 NAVER WORKS 1순위와 Baron UUID R2 2순위는 실기기 화면 및 서버 로그로 확인했다.
- 남은 확인은 DEFAULT 3순위와 직원 상세/조직도/즐겨찾기 화면 확대 검증이다.
3. OIDC callback 실서버 기준 정합성 유지
- 현재 문서 기준 redirect URI는 `https://114-auth.hmac.kr/api/v1/auth/oidc/callback`
- 실제 배포 기준 도메인/프록시와 일치하게 유지해야 한다.
### 4.2 `baron-sso-tdc114plus-api`에서 빼야 하는 것
1. `/api/v1/tdc114plus/...` 전용 경로에 대한 앱 직접 의존
2. 로컬 Baron compose가 항상 떠 있어야만 앱이 개발되는 구조
3. 사진 fallback용 PostgreSQL 기본안
## 5. 마이그레이션 순서 제안
1. `tdc114plus-auth link/poll` 사용자 메타데이터 확장을 먼저 반영한다. 완료.
2. 실기기 로그인 후 저장된 user/session에서 `tenantSlug` 등 메타데이터가 실제로 유지되는지 확인한다. 진행 예정.
3. Baron SSO 문자 링크 실제 발송/SMS 연계 이슈 답변을 먼저 받는다. 진행 중.
4. `/api/v1/profile-image`를 현재 auth 코드 기준으로 복구한다. 완료.
5. 앱 코드에서 남아 있는 `/api/v1/tdc114plus/...` 직접 의존을 다시 표로 수집한다. 진행 예정.
6. 조직/직원 데이터는 `org-context` 기준으로만 유지되도록 경계를 정리한다. 진행 예정.
7. 로컬 Baron worktree 없이도 `staging` 기준 로그인 + 직원검색 + 조직도 + 사진 fallback이 되는지 검증한다. 진행 예정.
8. 그 다음에 `production` 승격 체크리스트를 만든다. 진행 예정.
## 6. 현재 판단
2026-07-20 기준 현재 가장 중요한 사실은 아래 두 가지다.
1. `baron-sso-tdc114plus-api`는 이미 최종 배포 직접 대상이라기보다 개발 중 임시 worktree로 보는 것이 맞다.
2. `profile-image``tdc114plus-auth`에 유지할 배포 필수 기능으로 정리한다. 네이버웍스 원본 URL 직접 노출 문제는 auth 프록시로 처리한다.
따라서 다음 실제 작업은 아래 순서가 가장 안전하다.
1. DEFAULT 기본 아바타와 직원 상세/조직도/즐겨찾기 이미지 규칙 확대 검증
2. auth 응답 메타데이터 실기기 반영 유지 확인
3. 실기기 기능 재검증
4. 레거시 경로 제거
@@ -0,0 +1,194 @@
# tdc114plus 외부 API 사용 및 앱 내 활용 정리
작성일: 2026-07-03
최종 개정일: 2026-07-20
상태: v3.4
목적: `tdc114plus` 앱이 Baron SSO가 이미 제공하는 API를 어떤 원칙으로 직접 소비해야 하는지, 그리고 어떤 인증값이 앱에 들어가면 안 되는지 정리한다.
상위 기준 문서:
- `docs/00_policy_tdc114plus_development_2026-07-02.md`
- `docs/00_contract_tdc114plus_api_2026-07-02.md`
## 1. 한 줄 결론
- 신규 앱은 Baron SSO에 추가되는 별도 `RP`다.
- Baron SSO 원본에 신규앱 전용 `/api/v1/tdc114plus/...` API를 추가하지 않는다는 현재 기준을 따른다.
- 단, 앱 비밀값 보호와 모바일 세션 유지를 위해 `tdc114plus-auth` 중계서버는 별도 운영한다.
- 앱은 Baron SSO/조직도 사이트가 이미 제공하는 공식 API를 직접 소비하는 클라이언트다.
- 다만 앱에 넣으면 안 되는 운영 Key나 비밀값은 계속 서버/운영자 전용으로 본다.
- 현재 코드에 남아 있는 `tdc114plus` 전용 API 가정은 레거시 흔적으로 보고, 기능 보존 테스트를 동반해 점진 제거한다.
## 2. 현재 API 계층 해석
2026-07-08 기준 현재 해석은 아래와 같다.
| 계층 | 호출 주체 | 용도 | 비고 |
| --- | --- | --- | --- |
| Baron 공개/기존 API | Flutter 앱 | 조직도, 조직/사용자 데이터 조회 | Swagger에 노출된 endpoint 기준으로 재확인 필요 |
| Baron 로그인/웹 절차 | Flutter 앱 + 사용자 브라우저/링크 | RP 로그인, 승인 링크 처리 | Hosted Login + PKCE 원칙 |
| 운영 Key 기반 외부 연동 API | 운영 서버 또는 운영자 | 원본 데이터 조회 또는 관리자성 연동 | 모바일 앱 직접 탑재 금지 |
정리하면, Flutter 앱은 Baron SSO 원본에 신규앱 전용 API를 새로 요구하지 않는다. 대신 앱에 노출되면 안 되는 key/secret, Baron SSO 링크 로그인 중계, 앱 세션 JWT, 프로필 이미지 lookup은 `tdc114plus-auth`가 맡는다.
## 3. 신규 앱의 인증 해석
- `tdc114plus`는 Baron SSO에 추가되는 별도 `RP(Relying Party)`다.
- 앱 로그인은 Baron SSO의 RP 로그인 절차 일부다.
- 기본 인증 방식은 `Baron SSO Hosted Login + OIDC Authorization Code + PKCE`다.
- 흐름은 `앱 -> Baron SSO 인증 URL 열기 -> Hosted Login 화면에서 휴대폰번호 입력 -> 문자/메일 링크 인증 -> App Link callback -> token 교환 -> 앱 로그인 완료`다.
- 앱은 승인 링크를 직접 만들지 않고, 휴대폰번호/인증정보도 직접 처리하지 않는다.
- Swagger의 `phone-login`, `headless`, `enchanted-link`, `sms`, `qr` 계열 Auth API는 Baron SSO Hosted Login 화면과 서버 내부 구현의 참고 대상이다.
- Flutter 앱 기본 구현은 위 Auth API를 직접 조합하지 않고, OIDC authorization endpoint와 token endpoint를 사용한다.
- 로그인 성공 후 Baron SSO가 조직도 API 호출에 필요한 연동 키 묶음을 전달하면 앱은 이를 선택적 세션 값으로 받아 사용할 준비를 한다.
- 해당 기능이 Baron SSO에 구현되기 전까지는 staging `org-context` 검증을 위해 로컬 비추적 env/Dart define의 고정 키 fallback을 사용한다.
2026-07-20 현재 로컬 실기기 검증 흐름에서는 모바일 앱이 Baron SSO 원본을 직접 호출하지 않고 `tdc114plus-auth`의 아래 중계 API를 호출한다.
```http
POST /api/v1/auth/link/init
POST /api/v1/auth/link/poll
GET /api/v1/integrations/org-context
GET /api/v1/profile-image
```
위 API는 Baron SSO 원본에 새로 추가한 신규앱 API가 아니라, 신규앱 전용 중계서버 `tdc114plus-auth`의 API다.
## 4. 앱이 실제로 호출할 인증 API
현재 저장소 코드에는 아래 레거시 경로 가정이 남아 있거나 제거 대상이다.
```http
POST /api/v1/auth/headless/phone-login
POST /api/v1/auth/headless/link/poll
POST /api/v1/tdc114plus/auth/phone-login
```
하지만 2026-07-08 사용자 확인 기준으로 `tdc114plus 앱 전용 API는 없다`.
따라서 위 경로들 중 legacy `POST /api/v1/tdc114plus/auth/phone-login`을 포함한 레거시 표기는 `확정 계약`이 아니라 `기존 로컬/가정 기반 경로`로 격하한다.
처리 원칙:
1. 먼저 Swagger에서 대응 endpoint를 찾는다.
2. 그 다음 앱 DTO/repository를 대체 계약으로 맞춘다.
3. mock/real 테스트와 실제 화면 검증이 끝난 뒤에만 옛 경로를 제거한다.
현재 확정 사실:
- 신규 앱의 기본 로그인 정책은 Hosted Login + PKCE다.
- 사용자가 링크를 클릭해야 앱 로그인이 완료된다는 정책은 유지한다.
- 다만 링크 발송/승인 처리는 앱이 직접 headless API로 수행하지 않고 Baron SSO Hosted Login 화면과 서버 내부 구현에 맡긴다.
- Swagger에 `POST /api/v1/auth/phone-login` 또는 `POST /api/v1/auth/headless/phone-login`이 보이더라도, 앱의 기본 로그인 버튼은 이 API를 직접 호출하지 않는다.
## 5. 팀장 전달 외부 API 정보의 위치
현재 저장소 기준으로 팀장 전달 정보와 직접 연결되는 외부 연동 대상은 아래 API다.
```http
GET https://sadmin.hmac.kr/api/v1/integrations/org-context
X-Baron-Key-ID: {KEY_ID}
X-Baron-Key-Secret: {KEY_SECRET}
```
참고:
- Swagger UI: `https://sadmin.hmac.kr/api/docs#/Integrations/get_api_v1_integrations_org_context`
- OpenAPI: `https://sadmin.hmac.kr/api/openapi.yaml`
- 로컬 참고 문서: `docs/guide_baron_org_context_api_reference_2026-07-03.md`
중요 해석:
- 이 API는 앱 화면을 구성할 때 참고해야 하는 원본/공식 문서 계층이다.
- 장기적으로는 로그인 성공 후 Baron SSO가 내려주는 조직도 API 연동 키를 사용한다.
- 단기적으로는 Baron SSO의 키 전달 기능이 아직 미개발이므로, 개발/검증용 staging 고정 키를 로컬 비추적 env/Dart define에만 둔다.
- tracked 문서, tracked 소스, 운영 APK 기본값에는 `X-Baron-Key-ID`, `X-Baron-Key-Secret` 실제 값을 넣지 않는다.
## 6. 앱이 실제로 호출하는 데이터 API
현재 저장소 코드에는 아래 레거시 데이터 경로 가정이 남아 있다.
```http
GET /api/v1/tdc114plus/directory/employees
GET /api/v1/tdc114plus/directory/employees/{employeeId}
GET /api/v1/tdc114plus/organization/tenants
GET /api/v1/tdc114plus/organization/orgchart
```
하지만 이 역시 `확정 계약`으로 보지 않는다.
현재 확인된 근거:
- `sadmin.hmac.kr`는 org-context 참고 host이지 `tdc114plus` 앱 route host는 아니다.
- `sorg.hmac.kr/login?returnTo=%2Fchart`는 웹 로그인 진입 주소이며 JSON API가 아니라 HTML 응답을 돌려준다.
- Swagger 화면에는 `Public /api/v1/public/orgchart` 같은 조직도 관련 공개 API 흔적이 보인다.
따라서 앞으로의 기준은 아래와 같다.
1. 조직도/가족사/사용자 조회는 Swagger에 실제로 존재하는 `integrations/org-context`, `public/orgchart` 기준으로 다시 잡는다.
2. 현재 코드의 `/api/v1/tdc114plus/...` 경로는 전면 재확정 대상이다.
3. 정확한 path, query, 응답 스키마가 확인되기 전에는 코드 경로를 확정 표현으로 문서화하지 않는다.
4. 레거시 경로 정리는 `유지`, `교체`, `제거` 분류표를 먼저 만든 뒤 순차적으로 수행한다.
## 7. 원본 외부 API와 앱 기능의 연결
| 원본 정보 | 앱 또는 중간 가공 결과 | 앱 기능 |
| --- | --- | --- |
| 조직 트리 | `org-context` 또는 공개 `orgchart` 응답을 앱 내부 모델로 매핑 | 회사/조직 구조 표시 |
| 조직 목록 | 상단 칩용 tenant/company 모델 | 상단 필터 칩 |
| 조직 구성원 | 직원 목록/상세용 앱 모델 | 직원검색, 상세 |
| 사용자 전화번호 | `phoneNumber`, `phoneDisplay` | 전화/문자 실행 |
| 사용자 이메일/직급/직위/직무 | 동일 또는 유사 필드 | 상세 정보 표시 |
즉, 앱은 Swagger에 드러나는 원본/공개 응답을 앱 화면용 모델로 직접 매핑하는 구조로 전환될 수 있다.
## 8. 환경변수 및 보안 원칙
앱 측 런타임 값:
- `SSO_BASE_URL`
- `TDC114_API_BASE`
- `APP_VERSION`
서버 측 외부 API 연동 값 예시:
```env
TDC114PLUS_ORG_CONTEXT_BASE_URL=https://sadmin.hmac.kr
TDC114PLUS_ORG_CONTEXT_KEY_ID=
TDC114PLUS_ORG_CONTEXT_KEY_SECRET=
TDC114PLUS_ORG_CONTEXT_TENANT_SLUG=hanmac-family
TDC114PLUS_ORG_CONTEXT_INCLUDE_USERS=true
TDC114PLUS_ORG_CONTEXT_INCLUDE_USER_IDS=true
```
추가 메모:
- 2026-07-10 기준 팀장 지시에 따라 원본 Baron API 기준 host는 `https://sadmin.hmac.kr/`를 사용한다.
- 팀장 전달 운영 키(`CLIENT ID`, `X-Baron-Key-Secret`)는 로컬 비추적 env에만 반영하고 tracked 문서에는 직접 기록하지 않는다.
- 실제 배포 전까지 신규앱에서 사용하는 Baron SSO API 참고 기준은 staging Swagger `https://sadmin.hmac.kr/api/docs#/`다.
보안 원칙:
- `X-Baron-Key-ID`, `X-Baron-Key-Secret` 실제 값은 tracked 앱 소스와 tracked 문서에 넣지 않는다.
- 개발/검증 중 Baron SSO가 키 전달 기능을 제공하기 전까지는 비추적 env/Dart define fallback으로만 제한 사용한다.
- 운영 APK에서는 로그인 성공 후 받은 세션 기반 연동 키 또는 별도 안전한 서버 중계 방식으로 전환한다.
- 앱에는 공개 가능한 RP Client ID, issuer, redirect URI 같은 공개 설정만 기본값으로 둔다.
- RP 비밀값, 승인 링크 생성용 내부 인증정보, 외부 API Key는 모두 서버 전용이다.
- 앱이 저장하는 세션 정보는 운영 전 안전한 저장소 적용 여부를 재검토한다.
## 9. 팀 공유용 핵심 메시지
1. `tdc114plus`는 Baron SSO의 별도 RP다.
2. 로그인은 Baron SSO Hosted Login + PKCE 방식이며, Baron SSO 화면에서 휴대폰번호 입력 후 사용자가 문자/메일 링크를 클릭해야 앱 로그인이 완료된다.
3. 앱 전용 `tdc114plus` backend API는 없다는 현재 기준으로 재정렬한다.
4. 앱은 Baron이 이미 제공하는 Swagger 공개 API를 직접 소비하는 클라이언트 앱이다.
5. 운영 Key나 RP 비밀값은 모바일 앱과 저장소 tracked 파일에 두면 안 된다.
## 10. 현재 기준 주의사항
- `phone-login`은 개발용 fallback 경로일 뿐 기본 UX가 아니다.
- 현재 코드에 남아 있는 `/api/v1/tdc114plus/...` path는 검증 전 가정일 수 있다.
- 해당 가정 path 제거는 기능이 유지되는지 테스트한 뒤 단계적으로만 진행한다.
- 실제 앱 구조는 `auth`, `directory`, `organization` feature별 repository 분리 기준으로 유지하되, endpoint는 Swagger 기준으로 다시 매핑해야 한다.
- 실제 응답 정합성 검증 중이라, 세부 필드명과 사용 범위는 Swagger 기준으로 계속 재확인해야 한다.
@@ -0,0 +1,57 @@
# TDC114PLUS Headless/PKCE 계약 차이 검토
작성일: 2026-07-09
상태: 정책 재정렬 완료, headless 직접 호출은 기본 흐름에서 제외
2026-07-09 결론:
- TDC114PLUS 앱의 기본 로그인은 `Baron SSO Hosted Login + OIDC Authorization Code + PKCE`다.
- 앱은 `/api/v1/auth/headless/...` API를 직접 호출하지 않는다.
- 이 문서는 headless 직접 호출을 검토하던 시점의 계약 차이를 보존하되, 현재 실행 기준으로는 `참고/레거시 검토 문서`로 본다.
## 1. 확인된 RP 설정
- RP 유형: PKCE 공개 클라이언트
- Client ID: `39d6190d-72f6-4a58-a84f-cdc5ece3e8af`
- Redirect URI: `https://114.hmac.kr/auth/callback`
- Client Secret: 없음
- OIDC issuer: `https://sso.hmac.kr/oidc`
## 2. 확인된 표준 OIDC 기능
Discovery 문서에서 아래 기능을 확인했다.
- authorization code grant
- token endpoint 인증 방식 `none`
- PKCE `S256`
- authorization, token, userinfo endpoint
따라서 공개 모바일 앱이 Client Secret 없이 PKCE verifier로 authorization code를 교환하는 표준 흐름은 지원된다.
## 3. Headless API 계약 차이
공식 Swagger의 아래 API는 `client_assertion`을 필수로 요구한다.
- `POST /api/v1/auth/headless/phone-login`
- `POST /api/v1/auth/headless/link/poll`
Swagger는 `client_assertion``private_key_jwt`라고 정의한다. 이를 모바일 앱이 직접 생성하려면 RP 개인키가 APK에 포함되어야 하므로 공개 PKCE 앱 보안 모델과 맞지 않는다.
## 4. 적용 정책
- Client ID는 공개 식별자이므로 APK에 포함한다.
- Client Secret 또는 `private_key_jwt` 서명용 개인키는 APK에 포함하지 않는다.
- 표준 PKCE 생성, state 검증, authorization code 교환은 앱에 구현한다.
- 앱은 Baron SSO authorization endpoint를 열고, Baron SSO Hosted Login 화면이 휴대폰번호 입력과 문자/메일 링크 인증을 처리하게 한다.
- headless API 직접 호출은 현재 앱 기본 구현에서 제외한다.
- 추후 별도 신뢰 백엔드가 생기거나 Baron SSO가 모바일 공개 RP용 headless 계약을 제공하는 경우에만 별도 feature로 재검토한다.
## 5. Baron SSO 확인 요청
아래 질문은 앱이 headless API를 직접 호출해야 한다는 전제가 다시 살아날 때만 유효하다.
1. PKCE 공개 RP에서 `client_assertion` 생략이 가능한지
2. 불가능하다면 단기 assertion 발급 endpoint가 있는지
3. assertion의 issuer, subject, audience, 만료시간, 서명 알고리즘
4. `login_challenge`를 모바일 앱이 얻는 공식 절차
5. headless 승인 완료 후 `redirectTo`와 PKCE callback 연결 절차
@@ -0,0 +1,234 @@
# tdc114plus org-context 응답 매핑 판단서
작성일: 2026-07-08
상태: v1.0
목적: Baron Swagger의 `GET /api/v1/integrations/org-context` 설명과 예시 응답을 기준으로, 현재 `tdc114plus` 앱 화면 요소에 어떤 필드를 직접 매핑할 수 있는지와 어떤 부분은 추가 가공이 필요한지 판단한다.
관련 문서:
- `docs/00_contract_tdc114plus_api_2026-07-02.md`
- `docs/00_guide_tdc114plus_external_api_usage_2026-07-03.md`
- `docs/00_policy_tdc114plus_screen_feature_2026-07-07.md`
## 1. 결론
- `org-context`는 현재 기준으로 `조직도/직원 데이터 원형 API`로 매우 유력하다.
- 특히 `tenantSlug` 기준 subtree, `tree`, `tenants`, `members` 구조는 `전체 > 회사 > 하위조직 > 개인` 탐색 정책과 잘 맞는다.
- 다만 이 API는 `Integrations` 영역이며 `API Key`가 필요하므로, 모바일 앱이 운영 Key를 직접 넣고 호출하는 구조는 기본안으로 채택하면 안 된다.
- 따라서 `데이터 구조 기준 API`로는 채택 가능하지만, `실기기 앱 직접 호출 방식`은 보안 검토가 끝나기 전까지 확정하지 않는다.
## 2. Swagger에서 확인된 사실
대상 API:
```http
GET /api/v1/integrations/org-context
```
특징:
- 계정 세션 없이 `API Key`로 조회
- `tenantSlug`가 없으면 기본 `hanmac-family` subtree 반환
- `includeUsers=false`이면 tenant members는 빈 배열
- `includeUserIds=true`이면 `members[].id`, `members[].phone` 추가
주요 응답 구조:
- `scope.tenantId`
- `scope.tenantSlug`
- `tree`
- `tenants[]`
- `tenant.members[]`
즉, 조직 트리와 조직별 직접 소속 구성원 목록을 함께 주는 구조다.
여기서 `tenant.members[]``tenant.memberCount`는 해당 조직에 직접 붙어 있는 구성원 기준으로 해석한다.
화면의 하위조직 카드에 표시하는 인원 수는 직접 소속 수가 아니라, 해당 조직과 모든 descendant 조직의 직접 소속 인원을 합산한 `subtree 인원 수`로 앱에서 계산한다.
## 3. 현재 앱 기능과의 적합도 판단
### 3.1 매우 잘 맞는 부분
1. 회사/가족사 칩
- `tree.name`, `tree.slug`, `tenants[].name`, `tenants[].slug`, `parentId`
- 현재 상단 칩 구조와 직접 연결 가능
2. 하위조직 drilldown
- `tree.children[]`
- `tenant.parentId`
- 현재 정책의 `전체 > 회사 > 하위조직 > 개인` 탐색 구조와 잘 맞음
3. 조직별 소속 인원 표시
- `tenant.members[]`
- leaf 조직에서 직원 목록으로 진입하는 정책과 맞음
- 비-leaf 조직의 `members[]`는 직접 소속 인원이므로, 하위조직 목록과 한 화면에 섞어 표시하지 않는다
4. 직원 상세 기본 텍스트
- `members[].name`
- `members[].email`
- `members[].department`
- `members[].grade`
- `members[].position`
- `members[].jobTitle`
5. 조직도 정렬 힌트
- `members[].isOwner`
- `members[].isLeader`
- `members[].isPrimary`
- 현재 `팀장 우선` 정렬 정책에 보조 신호로 활용 가능
### 3.2 조건부로 맞는 부분
1. 전화/문자 기능
- `members[].phone`
- 하지만 Swagger 설명상 `includeUserIds=true`일 때만 포함
- 즉, 전화/문자 기능까지 쓰려면 `includeUserIds=true`가 사실상 필요
2. 직원 식별자 기반 상세/즐겨찾기
- `members[].id`
- 이것도 `includeUserIds=true`일 때만 포함
- 즐겨찾기/상세/프로필 이미지 매핑 안정성을 높이려면 필요
- 2026-07-15 실조회 기준 이 값은 실제 UUID 형식으로 내려오는 것을 확인했다
3. 초기 내 팀 뱃지 계산
- `members[].department``tenant.name` 매칭으로 어느 정도 가능
- 다만 `department` 문자열과 tenant 이름이 항상 1:1 대응하는지 추가 확인 필요
### 3.3 그대로는 부족한 부분
1. 앱 현재 `Employee` 모델의 `tenantId`, `tenantName`, `tenantSlug`
- `OrgContextMember` 안에는 tenant 정보가 직접 들어있지 않음
- 따라서 `tenant.members[]`를 순회하며 `상위 tenant 정보`를 멤버에 주입하는 앱 내부 flatten 가공이 필요
2. 현재 `EmployeeListResponse.items` 형태
- `org-context``items[]` 응답이 아니라 `tree + tenants[] + tenant.members[]` 구조
- 즉, 직원검색용 평탄 목록은 앱 내부에서 별도 생성해야 함
3. 현재 `TenantListResponse.items`
- `org-context``items[]` 래퍼가 아님
- `tenants[]` 또는 `tree.children[]``TenantSummary` 형태로 바꾸는 adapter 필요
4. `totalMemberCount`
- Swagger 예시에는 `memberCount`는 보이지만 `totalMemberCount`는 보장되지 않음
- 현재 앱 모델의 `totalMemberCount`는 앱 내부에서 descendant 포함 합산 계산이 필요
- 예: `CM본부` 자체 직접 소속이 0명이라도 하위 `CM사업부`에 511명이 있으면 `CM본부` 카드에는 511명으로 표시한다
- 반대로 leaf 조직의 직접 소속이 0명이면 leaf 진입 시 `검색 결과 없음` 표시가 정상일 수 있다
5. 프로필 사진 URL
- `profileImageUrl` 필드는 없음
- 사번/이미지 URL 직접 연결은 불가
## 4. 현재 Flutter 모델 기준 매핑 판단
### 4.1 `TenantSummary`
현재 필드:
- `id`
- `name`
- `slug`
- `type`
- `parentId`
- `memberCount`
- `totalMemberCount`
매핑 판단:
| 앱 필드 | org-context 소스 | 판단 |
| --- | --- | --- |
| `id` | `tenant.id` | 직접 가능 |
| `name` | `tenant.name` | 직접 가능 |
| `slug` | `tenant.slug` | 직접 가능 |
| `type` | `tenant.type` | 직접 가능 |
| `parentId` | `tenant.parentId` | 직접 가능 |
| `memberCount` | `tenant.memberCount` 또는 `tenant.members.length` | 직접 소속 수로 사용 |
| `totalMemberCount` | 없음 | 앱 내부에서 subtree 합산 계산 필요 |
### 4.2 `Employee`
현재 필드:
- `id`
- `name`
- `phoneNumber`
- `email`
- `tenantId`
- `tenantName`
- `tenantSlug`
- `department`
- `grade`
- `position`
- `jobTitle`
- `status`
- `profileImageUrl`
매핑 판단:
| 앱 필드 | org-context 소스 | 판단 |
| --- | --- | --- |
| `id` | `member.id` | `includeUserIds=true` 필요 |
| `name` | `member.name` | 직접 가능 |
| `phoneNumber` | `member.phone` | `includeUserIds=true` 필요 |
| `email` | `member.email` | 직접 가능 |
| `tenantId` | 상위 `tenant.id` | 가공 필요 |
| `tenantName` | 상위 `tenant.name` | 가공 필요 |
| `tenantSlug` | 상위 `tenant.slug` | 가공 필요 |
| `department` | `member.department` | 직접 가능 |
| `grade` | `member.grade` | 직접 가능 |
| `position` | `member.position` | 직접 가능 |
| `jobTitle` | `member.jobTitle` | 직접 가능 |
| `status` | 없음 또는 tenant status와 혼동 가능 | 재정의 필요 |
| `profileImageUrl` | 없음 | 별도 정책 필요 |
## 5. 화면 정책 기준 판단
### 5.1 바로 충족 가능한 정책
- 회사급 초기 범위
- 전체 > 회사 > 하위조직 > 개인 drilldown
- breadcrumb 유지
- leaf 조직 진입 후 직원 목록 표시
- 하위조직 카드의 subtree 인원 수 표시
- 조직도 그룹 내 리더 우선 정렬 보조
### 5.2 추가 가공 후 충족 가능한 정책
- 본인 팀 뱃지 고정 노출
- 선택 scope 기준 직원검색
- 즐겨찾기 로컬 저장
- 상세 화면 tenant/부서/직급/직위 표시
- 프로필 이미지 2순위 UUID 파일명 연결
### 5.3 현재 구조만으로는 바로 어려운 정책
- 프로필 사진 URL 표시
- 안정적인 직원 상세 단건 조회
- 서버 기반 즐겨찾기 동기화
## 6. 최종 판단
`org-context`는 다음 의미에서 채택 가치가 높다.
1. 조직도와 직원검색의 데이터 원형으로 충분히 쓸 수 있다.
2. 현재 앱이 원하는 조직 탐색 UX와 구조적으로 잘 맞는다.
3. 현재 코드의 `tdc114plus 전용 DTO`는 상당 부분 이 응답을 flatten/adapter 한 결과로 재해석할 수 있다.
하지만 아래 2가지는 분리해서 봐야 한다.
1. `데이터 구조 기준으로 채택할 것인가`
-
2. `모바일 앱이 운영 Key를 넣고 직접 호출할 것인가`
- 현재 기준으로는 보안 검토 전까지 아니오
### 6.1 2026-07-15 추가 확인
- 가족사 전체 `org-context` 응답과 기존 프로필 파일 CSV를 대조한 결과, 기존 `2457`건이 `members[].id`와 전건 매핑됐다.
- 따라서 현재 기준으로 `members[].id`는 프로필 이미지 2순위 식별자로 실사용 가능한 후보가 아니라, 사실상 채택 가능한 기준값으로 본다.
## 7. 다음 작업 권장 순서
1. `org-context`를 기준 데이터 구조로 채택한다고 문서에 명시
2. 현재 `Employee`, `TenantSummary`, `OrgChartSnapshot``org-context adapter` 기준으로 재설계
3. `includeUserIds=true`를 전제로 해야 하는 기능과 아닌 기능을 분리
4. 운영 Key 보호 방식을 확정
5. 그 다음 실제 코드 변경
@@ -0,0 +1,330 @@
# tdc114plus 프로필 이미지 관리 정책 및 단계별 진행안
작성일: 2026-07-14
최종 개정일: 2026-07-20
상태: v2.2
관련 Phase: `Phase 7-C`
## 1. 목적
신규앱의 직원 프로필 이미지를 어떤 우선순위와 어떤 식별 기준으로 운영할지 고정한다.
본 문서는 아래 사항을 최신 기준으로 정리한다.
- 프로필 이미지 노출 우선순위
- 네이버웍스와 Baron SSO 조직도 응답의 역할
- R2 버킷 파일명 정책
- 앱과 중계서버가 직접 하지 않아야 할 일
- 현재까지 확인된 검증 결과
- 다음 작업 순서
## 2. 현재 확정 정책
### 2-1. 이미지 노출 우선순위
신규앱의 프로필 이미지 우선순위는 아래처럼 고정한다.
1. 네이버웍스 프로필 사진
2. Baron SSO 조직도 `members[].id` 기준 UUID 파일명 이미지
3. 신규앱 기본 아바타
즉, R2 버킷 이미지는 유지하되, 더 이상 이메일 또는 해시 매핑 DB를 기본 경로로 사용하지 않는다.
### 2-2. 기본 식별자 정책
- 직원 프로필 이미지의 2순위 식별자는 Baron SSO 조직도 응답의 `members[].id`를 사용한다.
- 이 값은 `includeUsers=true`, `includeUserIds=true` 조건의 `org-context` 응답에서 확인되는 사용자 고정 식별자다.
- 신규앱은 `이메일 @앞부분.jpg`를 직접 만들지 않는다.
- 신규앱은 해시 파일명도 직접 계산하지 않는다.
- 신규앱은 직원별 Baron UUID를 확보한 뒤 `https://baroncs.co.kr/employee_img/{uuid}.jpg` 규칙으로만 2순위 이미지를 시도한다.
### 2-3. DB 사용 정책
- 현재 운영 기본안에서는 프로필 이미지 전용 매핑 DB를 두지 않는다.
- `tdc114plus-auth` 내부 PostgreSQL 기반 fallback 구현은 검토/검증 결과물로만 남기고, 현재 채택안의 기본 경로로 사용하지 않는다.
- 추후 Baron SSO UUID를 안정적으로 받을 수 없는 별도 화면이나 별도 운영 요구가 생기면 그때 보조안으로만 재검토한다.
## 3. 현재까지 확인된 사실
### 3-1. 네이버웍스 문서 및 실연동 검토 결과
2026-07-15 기준 네이버웍스 개발자 문서와 실제 서비스 계정 호출 기준으로 아래를 확인했다.
- 구성원 프로필 조회 endpoint: `GET /users/{userId}`
- 구성원 사진 조회 endpoint: `GET /users/{userId}/photo`
- `userId`에는 구성원 ID, 메일, 리소스 ID, `externalKey:{externalKey}` 형식 사용 가능
- 사진 조회 응답은 `HTTP 302`, `HTTP 400`, `HTTP 404` 규칙을 가진다
실제 1차 호출 결과:
- 서비스 계정 토큰 발급: `HTTP 200`
- `GET /users/{userId}` 샘플 1
- 입력: `khkang@samaneng.com`
- 결과: `HTTP 200`
- `GET /users/{userId}/photo` 샘플 1
- 입력: `khkang@samaneng.com`
- 결과: `HTTP 404`
- `GET /users/{userId}` 샘플 2
- 입력: `thlee3@samaneng.com`
- 결과: `HTTP 200`
- `GET /users/{userId}/photo` 샘플 2
- 입력: `thlee3@samaneng.com`
- 결과: `HTTP 302`
- `Location` 헤더에 실제 이미지 접근 URL 반환 확인
현재 판단:
- 네이버웍스 사진 연동은 1순위 경로로 실제 성립한다.
- 현재까지의 1차 검증 기준으로는 `사진 있음 -> 302`, `사진 없음 -> 404` 흐름이 확인됐다.
### 3-2. R2 버킷과 공개 경로
현재 프로필 이미지 파일은 Cloudflare R2 Object Storage에 적재되어 있다.
- 공개 도메인 prefix: `https://baroncs.co.kr/employee_img/`
- 현재부터는 UUID 파일명 기준으로 관리한다.
- 목표 URL 규칙: `https://baroncs.co.kr/employee_img/{uuid}.jpg`
2026-07-15 기준 UUID 파일명 샘플 공개 URL 검증 결과:
- `https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg`
- `HTTP 200`
- `content-type: image/jpeg`
- `https://baroncs.co.kr/employee_img/cc2db80c-5a18-4439-8026-22dd06d6452f.jpg`
- `HTTP 200`
- `content-type: image/jpeg`
현재 판단:
- UUID 파일명으로 변경된 2순위 이미지가 실제 공개 경로에서 응답하는 것을 확인했다.
- 따라서 2순위 경로는 문서상 계획이 아니라 실배치/실응답까지 확인된 상태로 본다.
### 3-3. Baron SSO 조직도 UUID 실검증 결과
2026-07-15 기준 `org-context` 실조회로 아래 사실을 확인했다.
- 조회 대상: `tenantSlug=hanmac-family`
- 옵션: `includeUsers=true`, `includeUserIds=true`
- `members[].id`가 실제 응답에 포함된다
- 해당 값은 예시 문자열이 아니라 UUID 형식이다
- 예:
- `c6ac492a-d4f3-4fff-8409-b50e317ca793`
- `73f80ef0-62ec-49b2-a8eb-1f4de52966b7`
- `bebb832c-052e-493d-b5a9-732518d67685`
현재 판단:
- `members[].id`는 2순위 프로필 이미지 파일명 기준으로 사용할 수 있다.
- 이메일 local-part보다 보안성과 변경 내성이 높다.
### 3-4. 가족사 전인원 UUID 매핑 확인 결과
2026-07-15 기준 가족사 전체 `org-context` 응답과 기존 파일 매핑 CSV를 대조해 아래를 확인했다.
- 기준 원본: `docs/references/file_rename_hash_results.csv`
- 기존 원본 행 수: `2457`
- `org-context` 고유 이메일 수: `2610`
- UUID 매핑 성공 행 수: `2457`
- 미매핑 행 수: `0`
생성한 참고 산출물:
- `docs/references/profile_image_uuid_rename_candidates.csv`
이 CSV에는 아래 컬럼을 포함했다.
- `comp`
- `employee_name`
- `employee_email`
- `employee_email_local_part`
- `legacy_photo_file_name`
- `hashed_photo_file_name`
- `baron_user_uuid`
- `target_uuid_file_name`
- `rename_ready`
현재 판단:
- 기존 사진 파일을 UUID 파일명으로 재정리하는 작업은 충분히 진행 가능하다.
- 현재 확보된 CSV만으로 `기존 파일명 -> UUID 파일명` 변경 작업 계획을 잡을 수 있다.
## 4. 채택안 기준 연동 구조
### 4-1. 신규앱 기본 흐름
```text
직원 DTO 수신
-> 앱이 직접 네이버웍스와 외부 이미지 서버를 각각 판단하지 않음
-> 중계서버 /api/v1/profile-image 호출
-> 중계서버가 네이버웍스 사진 조회 시도
-> 실패 시 Baron UUID 기준 https://baroncs.co.kr/employee_img/{uuid}.jpg 판단
-> 둘 다 없으면 found=false 반환
-> 앱은 기본 아바타 표시
```
현재 시점의 가장 효율적인 구현안은, 앱이 직접 1순위/2순위를 분기하지 않고 기존 `tdc114plus-auth``GET /api/v1/profile-image` endpoint를 유지한 채 내부 우선순위만 `네이버웍스 -> UUID 이미지 -> 기본 미발견`으로 바꾸는 방식이다.
2026-07-20 기준 네이버웍스 1순위 이미지는 외부 `Location` URL을 앱에 그대로 전달하지 않는다.
이유:
- 네이버웍스 `GET /users/{userId}/photo`는 사진이 있으면 `302 Location`을 반환한다.
- 일부 `Location` URL은 앱이나 일반 HTTP 클라이언트가 인증 없이 직접 열면 `400 Authentication failed`가 발생한다.
- 따라서 앱이 네이버웍스 원본 URL을 직접 표시하는 방식은 안정적이지 않다.
현재 확정 처리:
- `tdc114plus-auth`가 네이버웍스 사진 존재 여부를 확인한다.
- 네이버웍스 사진이 있으면 앱에는 `tdc114plus-auth`의 프록시 URL을 반환한다.
- 앱은 이 프록시 URL을 일반 이미지 URL처럼 표시한다.
- 프록시는 내부에서 네이버웍스 Access Token을 사용해 실제 이미지 바이너리를 받아 앱에 `image/jpeg`로 내려준다.
- 네이버웍스 사진이 없거나 프록시 확인이 실패하면 UUID 이미지 2순위로 내려간다.
이 방식의 장점은 아래와 같다.
- 앱 계약을 크게 흔들지 않는다.
- 네이버웍스 서비스 계정/토큰 처리 로직을 앱에 넣지 않아도 된다.
- 추후 우선순위가 바뀌어도 서버만 수정하면 된다.
- UUID 이미지 경로 변경, 캐시 정책, timeout 정책을 서버에서 통제할 수 있다.
### 4-2. 신규앱이 직접 하지 않아야 하는 일
- `이메일 @앞부분.jpg` 직접 조합
- 해시 파일명 직접 계산
- 별도 프로필 이미지 매핑 DB 직접 조회
- 버킷 내부 경로 추론
### 4-3. 중계서버 역할
현재 채택안 기준 중계서버의 역할은 아래처럼 정리한다.
- Baron SSO 로그인/세션 유지
- 필요 시 조직도 `org-context` proxy 제공
- `GET /api/v1/profile-image` 단일 endpoint 제공
- 네이버웍스 1순위 경로 처리
- 네이버웍스 사진 원본 URL을 앱에 직접 노출하지 않고 `GET /api/v1/profile-image/naver-photo` 프록시로 이미지 바이너리 제공
- Baron UUID 기준 2순위 이미지 경로 판단
- 앱에는 최종 `found/source/imageUrl` 결과만 반환
현재 채택안 기준으로는 중계서버가 프로필 이미지 전용 매핑 DB를 운영 기본 구조로 갖지 않는다. 다만 기존 `tdc114plus-auth``/api/v1/profile-image` endpoint는 유지하고, 내부 로직만 새 우선순위로 재정리하는 것이 가장 효율적이다.
## 5. PostgreSQL 검토 결과 정리
2026-07-15 기준 `tdc114plus-auth`에 PostgreSQL 기반 fallback 검토를 이미 진행했다.
확인된 내용:
- 로컬 PostgreSQL 컨테이너 구성
- `profile_image_mapping` 스키마 초안 작성
- CSV `2457`건 적재 검증
- `GET /api/v1/profile-image?comp=...&email=...` 샘플 검증
현재 정책 판단:
- 위 결과는 기술 검증 자료로는 유효하다.
- 하지만 운영 기본안은 아니다.
- 현재 기준 주 경로는 `네이버웍스 -> UUID 파일명 이미지 -> 기본 아바타`다.
- 따라서 PostgreSQL 경로는 `보류된 대안`으로만 기록한다.
- 실제 구현은 기존 `tdc114plus-auth` endpoint를 재사용하되, PostgreSQL 분기는 기본 비활성 또는 최후 예비안으로만 남기는 편이 적절하다.
## 6. 단계별 진행작업 타임테이블
| 단계 | 상태 | 작업 구분 | 작업 내용 | 완료 기준 |
| --- | --- | --- | --- | --- |
| Step 1 | 완료 | 문서/정책 | 프로필 이미지 우선순위와 기본 검토 구조를 정리했다 | 초기 문서 기준선 확보 |
| Step 2 | 완료 | 네이버웍스 문서 검토 | `GET /users/{userId}`, `GET /users/{userId}/photo`, scope, 302/404 구조를 확인했다 | 1순위 경로 성립 가능 조건 확인 |
| Step 3 | 완료 | R2 자산 점검 | 공개 prefix `https://baroncs.co.kr/employee_img/` 와 공개 응답을 확인했다 | 2순위 파일 배치 경로 확인 |
| Step 4 | 완료 | Baron UUID 실검증 | `org-context` 실응답에서 `members[].id`가 UUID 형식으로 내려오는 것을 확인했다 | 2순위 식별자 확정 |
| Step 5 | 완료 | 가족사 전인원 매핑 검증 | 기존 CSV `2457`건을 Baron UUID와 대조해 전건 매핑 성공을 확인했다 | `profile_image_uuid_rename_candidates.csv` 생성 |
| Step 6 | 완료 | 외부 파일 재배치 | 기존 사진 파일명을 `[uuid].jpg`로 변경하고 `employee_img/` 하위에 재배치했다 | 샘플 URL 2건 `HTTP 200 image/jpeg` 검증 완료 |
| Step 7 | 완료 | 앱 공통 resolver 정리 | 앱은 `GET /api/v1/profile-image`를 우선 호출하고, 응답 실패 또는 미발견 시에는 `profileImageUrl -> UUID 파일명 -> 기본 아바타` 순서의 보조 fallback을 사용하도록 정리했다 | 앱 공통 resolver와 보조 fallback 반영 완료 |
| Step 8 | 완료 | 네이버웍스 실연동 검증 | 개발자 계정 기준으로 사진 조회를 실제 호출해 `302``404` 규칙을 확인했다 | 1순위 경로 실응답 규칙 1차 확정 |
| Step 9 | 완료 이력 | 서버 구현 및 실검증 | 2026-07-15 시점에는 `tdc114plus-auth GET /api/v1/profile-image``NAVER WORKS -> Baron UUID 이미지 -> DEFAULT` 순서로 동작하는 구현과 샘플 응답을 확인했다 | 2026-07-15 기준 실검증 이력 |
| Step 10 | 완료 | 현재 기동 정합성 검증 | 2026-07-20 기준 `tdc114plus-auth``/api/v1/profile-image``/api/v1/profile-image/naver-photo` 프록시 route를 복구/보강했다 | `go test ./cmd/server` 통과, 5001 health 정상 |
| Step 11 | 완료 | 네이버웍스 프록시 검증 | 네이버웍스 302 Location을 앱에 직접 주지 않고 중계서버 프록시가 `image/jpeg` 바이너리로 내려주는 것을 확인했다 | `한치영`, `이태훈` 프록시 `200 OK image/jpeg` 확인 |
| Step 12 | 진행 중 | 실기기 화면 확대 검증 | 직원검색 목록에서 NAVER_WORKS와 BARON_UUID_R2가 동시에 정상 표시되는 것을 확인했다. DEFAULT 기본 아바타 케이스는 추가 샘플로 계속 확인한다 | 1순위/2순위 화면 확인 완료, 3순위 확대 검증 필요 |
## 7. 다음 작업 순서
1. DEFAULT 기본 아바타 케이스를 명시 샘플로 추가 확인한다.
2. 직원 상세/조직도/즐겨찾기 화면에서도 동일 이미지 규칙이 유지되는지 확인한다.
3. 화면 전환 후 이미지 캐시가 잘못된 null 상태를 유지하지 않는지 확인한다.
4. `adb reverse 5001/5000`이 끊겼을 때 로그인 실패처럼 보일 수 있으므로, 실기기 검증 전 reverse 상태를 먼저 확인한다.
5. 검증 결과를 본 문서와 타임테이블 문서에 즉시 반영한다.
## 8. 현재 시점 결론
- 프로필 이미지 1순위는 네이버웍스다.
- 2순위는 Baron SSO 조직도 `members[].id`를 파일명으로 사용하는 UUID 이미지다.
- 현재 운영 기본안에서는 프로필 이미지 전용 DB를 두지 않는다.
- 기존 PostgreSQL fallback 검토는 예비안으로만 남긴다.
- 2026-07-20 기준 `tdc114plus-auth``/api/v1/profile-image` route와 네이버웍스 프록시 route는 복구/보강됐다.
- 네이버웍스 사진 원본 URL은 앱에 직접 주지 않고 `tdc114plus-auth` 프록시 URL로 제공한다.
- 현재 가장 중요한 다음 단계는 실기기 기준으로 `DEFAULT` 3순위와 직원 상세/조직도/즐겨찾기 화면의 동일 규칙 유지 여부를 확대 검증하는 것이다.
## 9. 2026-07-20 검증 및 보강 메모
2026-07-20 기준 아래를 확인했다.
- `tdc114plus-auth GET /api/v1/profile-image` route 복구 및 유지
- `tdc114plus-auth GET /api/v1/profile-image/naver-photo` 프록시 route 추가
- `cyhan@samaneng.com`, `thlee3@samaneng.com` 네이버웍스 사진 존재 확인
- 네이버웍스 `Location` 원본 URL 직접 접근 시 `400 Authentication failed`가 발생할 수 있음 확인
- 프록시 route에서 두 사용자 모두 `200 OK`, `Content-Type: image/jpeg` 확인
- 실기기 직원검색 화면에서 기존에 기본 이니셜로 떨어지던 인원의 네이버웍스 사진 표시 정상화 확인
- `BARON_UUID_R2` source 로그도 동시에 확인되어 2순위 경로가 유지됨을 확인
검증한 자동 테스트:
- `tdc114plus-auth`: `GOCACHE=/tmp/go-build-cache go test ./cmd/server`
- `tdc114plus`: `./scripts/flutter-docker.sh test test/directory/profile_image_api_client_test.dart`
주의:
- 로컬 실기기 검증 중 `adb reverse tcp:5001 tcp:5001`, `adb reverse tcp:5000 tcp:5000`이 끊기면 앱 화면에는 `인증 서버에 연결하지 못했습니다`처럼 보일 수 있다.
- 이 경우 프로필 사진 코드 문제가 아니라 실기기와 로컬 중계서버 연결 문제일 수 있으므로, 먼저 `adb reverse --list`를 확인한다.
## 10. 2026-07-15 실기기 확인 메모
2026-07-15 기준 Android 실기기 직원검색 화면에서 실제 프로필 사진 노출을 확인했다.
확인 내용:
- 로그인 후 직원검색 목록 진입 확인
- 원형 기본 이니셜 아바타 대신 실제 사진 노출 확인
- 확인 화면에는 여러 직원의 사진이 동시에 표시됐다
- 현재 앱은 `tdc114plus-auth /api/v1/profile-image`를 우선 호출하고, 응답 실패 또는 미발견 시 UUID 공개 경로 fallback을 사용한다
현재 판단:
- 정책 문서상 구조가 아니라, 실기기 화면 기준으로도 프로필 사진 표시가 동작하는 상태다
- 다만 어떤 직원이 `NAVER_WORKS`로 해석됐는지, 어떤 직원이 `BARON_UUID_R2`로 해석됐는지는 추가 로그/샘플 검증으로 더 구분해 둘 필요가 있다
## 9. 2026-07-15 자격값 재검증 메모
2026-07-15 오후 기준, 갱신된 네이버웍스 서비스 계정 자격값을 다시 반영한 뒤 실제 호출을 재검증했다.
확인 결과:
- 서비스 계정 토큰 발급: `HTTP 200`
- 샘플 사용자 `thlee3@samaneng.com` 프로필 조회: `HTTP 200`
- 샘플 사용자 `thlee3@samaneng.com` 사진 조회: `HTTP 302`
- `Location` 헤더로 실제 이미지 접근 URL 반환 확인
현재 판단:
- 최신 자격 세트는 유효하다.
- `tdc114plus-auth` 서버가 이 자격 세트를 사용해 1순위 경로를 실제 구현할 수 있는 준비가 됐다.
- 이후 로컬 auth 서버 실기동 검증에서도 아래를 확인했다.
- `GET /api/v1/profile-image?email=thlee3@samaneng.com`
- `found=true`
- `source=NAVER_WORKS`
- `GET /api/v1/profile-image?email=khkang@samaneng.com`
- `found=true`
- `source=BARON_UUID_R2`
## 10. 관련 문서
- `docs/00_guide_tdc114plus_work_progress_timetable_2026-07-02.md`
- `docs/00_guide_tdc114plus_org_context_mapping_2026-07-08.md`
- `docs/00_contract_tdc114plus_api_2026-07-02.md`
@@ -0,0 +1,163 @@
# tdc114plus Swagger API 목록 및 Feature 매핑
작성일: 2026-07-07
상태: v1.2
목적: 타임테이블의 다음 작업인 `Swagger 기준 1차 사용 API 목록 고정`, `현재 Flutter feature 매핑`, `구조 차이점 정리`를 한 문서에 정리한다.
상위 기준 문서:
- `docs/00_guide_tdc114plus_work_progress_timetable_2026-07-02.md`
- `docs/00_contract_tdc114plus_api_2026-07-02.md`
- `docs/00_policy_tdc114plus_development_2026-07-02.md`
주의:
- 현재 기준은 Baron Swagger 문서와 앱 소스의 대조 결과를 합친 중간 정리본이다.
- 신규앱 전용 `/api/v1/tdc114plus/...` API는 공식 계약으로 간주하지 않는다.
-`/api/v1/tdc114plus/...` 경로는 과거 구현 흔적 또는 호환 경로이며, 기능이 무너지지 않게 테스트하면서 점진 제거한다.
## 1. 1차 사용 API 목록
현재 Baron Swagger 기준 1차 사용 또는 즉시 검토 대상 API는 아래다.
| 구분 | Method | Path | 현재 상태 | 앱 사용 목적 |
| --- | --- | --- | --- | --- |
| auth | `GET` | `https://sso.hmac.kr/oidc/oauth2/auth` | RP 설정 기준 | Baron SSO Hosted Login 시작 |
| auth | `POST` | `https://sso.hmac.kr/oidc/oauth2/token` | RP 설정 기준 | PKCE authorization code token 교환 |
| auth | `GET` | `https://sso.hmac.kr/oidc/userinfo` | RP 설정 기준 | 로그인 사용자 정보 조회 |
| auth reference | `POST` | `/api/v1/auth/phone-login` | 앱 직접 호출 제외 | Baron SSO Hosted Login 내부 전화번호 로그인 구현 참고 |
| auth reference | `POST` | `/api/v1/auth/headless/phone-login` | 앱 직접 호출 제외 | Baron SSO Hosted Login 내부 구현 참고 |
| auth reference | `POST` | `/api/v1/auth/headless/link/poll` | 앱 직접 호출 제외 | Baron SSO Hosted Login 내부 구현 참고 |
| auth reference | `POST` | `/api/v1/auth/enchanted-link/init` | 앱 직접 호출 제외 | Baron SSO 링크 로그인 내부 구현 참고 |
| auth reference | `POST` | `/api/v1/auth/enchanted-link/poll` | 앱 직접 호출 제외 | Baron SSO 링크 로그인 내부 구현 참고 |
| auth reference | `POST` | `/api/v1/auth/sms`, `/api/v1/auth/verify-sms` | 앱 직접 호출 제외 | Baron SSO SMS 인증 내부 구현 참고 |
| auth reference | `POST` | `/api/v1/auth/qr/*` | 앱 직접 호출 제외 | Baron SSO QR 인증 내부 구현 참고 |
| organization | `GET` | `/api/v1/integrations/org-context` | 공식 계약 | 조직 subtree/구성원 조회 |
| organization | `GET` | `/api/v1/public/orgchart` | 공식 계약 | 공유용 조직도 조회 |
| legacy auth | `POST` | `/api/v1/tdc114plus/auth/phone-login` | 호환 흔적 | 테스트용 세션 seed 예외 경로 |
| legacy directory | `GET` | `/api/v1/tdc114plus/directory/employees` | 제거 대상 | 과거 직원검색 목록 가정 |
| legacy organization | `GET` | `/api/v1/tdc114plus/organization/tenants` | 제거 대상 | 과거 회사 필터 가정 |
| legacy organization | `GET` | `/api/v1/tdc114plus/organization/orgchart` | 제거 대상 | 과거 조직도 가정 |
## 2. Feature별 현재 매핑
### 2.1 auth
관련 코드:
- `app/lib/src/features/auth/data/auth_api_client.dart`
- `app/lib/src/features/auth/data/auth_repository.dart`
현재 매핑:
| 기능 | 기준 API | 비고 |
| --- | --- | --- |
| 로그인 시작 | `GET https://sso.hmac.kr/oidc/oauth2/auth` | 기본 로그인 경로. 외부 Baron SSO Hosted Login 화면을 연다 |
| callback 처리 | `https://114.hmac.kr/auth/callback` | App Link로 앱 복귀 |
| token 교환 | `POST https://sso.hmac.kr/oidc/oauth2/token` | PKCE `code_verifier`로 authorization code 교환 |
| 사용자 정보 | `GET https://sso.hmac.kr/oidc/userinfo` | 후속 연결 대상 |
| fallback 로그인 | legacy `POST /api/v1/tdc114plus/auth/phone-login` | 예외 호환 경로만 유지 |
| 세션 저장 | API 아님 | `AuthSessionStore`에서 로컬 저장 |
구조 메모:
- `AuthRepository` 추상화는 이미 존재한다.
- 구현체 이름은 `RemoteAuthRepository`로 일반화했다.
- `OidcLoginRepository`는 Hosted Login authorization URL 생성, PKCE transaction 저장, callback token 교환을 담당한다.
- Swagger Auth 섹션에 표시되는 `phone-login`, `headless`, `enchanted-link`, `sms`, `qr` 계열 API는 Baron SSO Hosted Login 화면/서버 내부 구현 참고 대상으로 분류한다.
- 기존 headless/legacy phone login 계층은 테스트 호환 및 제거 대상 분류용으로만 유지한다.
- 로그인 성공 후 Baron SSO가 `org-context` 호출용 연동 키를 내려주면 앱 세션의 선택적 credential로 받아 사용한다.
- 해당 기능이 미개발인 동안은 staging `org-context` 고정 키를 비추적 env/Dart define fallback으로 사용한다.
### 2.2 directory
관련 코드:
- `app/lib/src/features/directory/data/directory_api_client.dart`
- `app/lib/src/features/directory/data/directory_repository.dart`
현재 매핑:
| 기능 | 기준 API | 비고 |
| --- | --- | --- |
| 직원 목록/검색 | `GET /api/v1/integrations/org-context` 기반 재구성 | 메인 직원검색 진입점 |
| 직원 상세 | `org-context` member 필드 또는 후속 공식 API 확인 필요 | API 계약 재정렬 중 |
구조 메모:
- `DirectoryRepository` 추상화는 존재한다.
- 구현체 이름은 `RemoteDirectoryRepository`로 일반화했다.
- 현재는 directory repository를 `org-context` 기반 구조로 재정렬하는 단계다.
### 2.3 organization
관련 코드:
- `app/lib/src/features/organization/data/organization_api_client.dart`
현재 매핑:
| 기능 | 기준 API | 비고 |
| --- | --- | --- |
| 테넌트/상위 조직 | `GET /api/v1/integrations/org-context` | 실제 화면 사용 높음 |
| 조직도/공유형 | `GET /api/v1/public/orgchart` | 공유/외부 링크 전용 |
구조 메모:
- organization 쪽은 repository 추상화가 추가됐다.
- `orgContextProvider`를 통해 subtree 조회를 별도 책임으로 분리한다.
- 1차 화면 재사용 범위는 `orgContextProvider`까지로 고정한다.
- `org-context` 전용 provider를 준비하되, 현재 직원검색 화면 연결은 점진 전환한다.
## 3. 구조 차이점 및 정리 우선순위
### 3.1 우선순위 1
- Hosted Login + PKCE를 기본 로그인 계약으로 고정
- 앱 내부 phone 입력/headless 직접 호출 UI와 API 의존 제거
- callback `state` 검증, token 교환, userinfo 조회 연결
### 3.2 우선순위 2
- organization 상태 재사용 범위를 `org-context` 중심으로 먼저 고정
- `org-context` 전용 provider를 준비하고, 실제 UI 연결은 후속 단계로 분리
- directory 화면에서 subtree/member 비동기 조합 방식을 더 다듬을지 검토
### 3.3 우선순위 3
- 직원 상세 API를 화면에서 실제로 쓰는 범위를 확대할지 판단
- orgchart client를 drilldown 화면에 실제 연결할지 판단
## 4. 현재 확인된 구조상 이슈
1. 일부 코드와 문서에 legacy `/api/v1/tdc114plus/...` 흔적이 남아 있다.
2. 실제 `org-context` 응답 필드 대조가 끝나기 전까지는 DTO 필드 확정 표현을 최소화해야 한다.
3. Baron SSO의 로그인 성공 응답 또는 후속 userinfo/session 응답에 조직도 API 연동 키가 아직 포함되지 않을 수 있다.
4. 따라서 앱은 `세션 credential 우선 -> 비추적 env fallback` 순서로 구현되어야 한다.
## 5. 다음 코드 작업 제안
다음 코드 작업은 아래 순서를 권장한다.
1. organization 상태 재사용은 `orgContextProvider` 우선으로 유지
2. legacy `/api/v1/tdc114plus/...` 의존은 테스트를 곁들여 단계적으로 제거
3. 실제 UI 연결 전 `org-context` 매핑과 member 검색 규칙을 먼저 고정
4. auth session 모델에 선택적 `orgContextCredential` 수신/저장 구조를 추가
5. `org-context` client는 session credential을 우선 사용하고 없으면 staging env fallback을 사용
## 6. 이 문서로 완료된 타임테이블 항목
이 문서로 아래 작업을 1차 수행했다.
- Swagger 기준 1차 사용 API 목록 문서화
- 현재 Flutter 코드의 auth/directory/organization feature 매핑
- feature별 DTO/repository/mock 구조 차이점의 초안 정리
- auth feature의 기본 로그인 경로와 fallback 경로 역할 분리
- directory repository와 organization repository의 1차 책임 경계 분리
- organization 상태 재사용 범위와 orgchart 연결 범위 1차 확정
- orgchart 전용 provider 준비 완료, 실제 UI 연결은 후속 단계로 유지
- auth/directory 구현체 명칭을 `Remote...Repository`로 일반화
- auth/directory 구현체 명칭 일반화 후 관련 테스트 통과
다만 실제 Swagger UI 대조 확인은 후속 작업으로 남아 있다.
@@ -0,0 +1,367 @@
# tdc114plus 작업진행 절차 및 타임테이블
작성일: 2026-07-02
최종 개정일: 2026-07-20
상태: v3.0
목적: `tdc114plus` 개발 작업을 새 분리형 API 전환 정책 기준으로 어떤 순서로 진행할지 고정하고, 현재 진행 상태를 최신 기준으로 유지한다.
상위 기준 문서:
- `docs/00_policy_tdc114plus_decoupled_api_migration_2026-07-07.md`
- `docs/00_policy_tdc114plus_development_2026-07-02.md`
- `docs/00_contract_tdc114plus_api_2026-07-02.md`
## 1. 현재 기준
`tdc114plus`는 Baron SSO에 추가되는 별도 `RP(Relying Party)` 성격의 신규 앱이다.
현재 구조는 `개발 중 임시 운영 구조``최종 배포 목표 구조`를 구분해서 본다.
- 개발 중 임시 운영 구조:
- 앱 저장소 `tdc114plus`
- 중계서버 저장소 `tdc114plus-auth`
- Baron SSO API 검증용 로컬 worktree `baron-sso-tdc114plus-api`
- 최종 배포 목표 구조:
- 신규앱은 `tdc114plus` + `tdc114plus-auth` 기준으로 배포 준비를 진행한다.
- 인증/조직 원본은 로컬 Baron worktree가 아니라 Baron SSO 원본 `staging`을 먼저 바라본다.
- 안정화 확인 후 Baron SSO 원본 `production`을 바라보는 구조로 전환한다.
- 따라서 `baron-sso-tdc114plus-api`는 현재 개발/검증용 임시 worktree로 보고, 최종 운영 필수 구성으로 간주하지 않는다.
현재 1차 범위:
- Baron SSO Hosted Login + PKCE 로그인
- 직원검색
- 가족사/조직 탐색
- 조직도
- 직원 상세
- 전화걸기/문자보내기
- 즐겨찾기 로컬 저장
1차 보류:
- 공지사항
- 전자결재
- 수신전화식별
- 수신팝업
- 서버 기반 즐겨찾기 동기화
## 2. 작업진행 절차
| 순서 | 단계 | 상태 | 작업 내용 | 산출물 |
| --- | --- | --- | --- | --- |
| 1 | 저장소 및 앱 골격 준비 | 완료 | Flutter 프로젝트, 기본 문서, 기본 스크립트, 테스트 기반 구성 | `app/`, `docs/`, `scripts/` |
| 2 | Mock 기반 화면 구축 | 완료 | 직원검색/조직도/상세/즐겨찾기 화면을 mock 데이터로 우선 구현 | 동작 가능한 UI 및 widget test |
| 3 | API 연동 1차 구현 | 진행 중 | auth, directory, organization API client/repository 및 세션 저장 흐름 구현 | Flutter API 계층, 단위 테스트 |
| 4 | Hosted Login + PKCE 전환 | 진행 중 | Baron SSO 인증 URL 생성, App Link callback, state 검증, PKCE token 교환, 세션 저장 흐름을 기본 로그인으로 반영 | auth client/repository/UI |
| 5 | 분리형 API 정책 정리 | 완료 | RP, Swagger 중심 계약, mock/real 병행 개발 원칙 문서화 | 정책 문서 개정본 |
| 6 | Swagger 기준 계약 재정렬 | 진행 중 | 실제 사용 endpoint와 DTO를 Swagger 기준으로 재확정하고 1차 사용 API/feature 매핑 문서를 추가했다 | 계약 문서 및 feature별 매핑 |
| 7 | Repository/Mock 구조 정리 | 진행 중 | feature별 remote/mock 구현을 더 명확히 분리하기 위해 organization repository 추상화를 추가하고 directory의 직접 API client 의존을 한 단계 분리했다 | repository interface 및 mock 구현 |
| 8 | 실제 API 정합성 검증 | 완료 | 로그인, 직원검색, 조직/테넌트 응답의 실제 정합성 점검 | smoke/integration 결과 |
| 8-A | Android integration runtime 기준 정렬 | 완료 | 실제 API smoke 완료 후 Android target bridge 포트와 integration 실행 기준을 최신 정책값으로 정렬하고 재검증했다 | target/ADB 점검 결과 및 재실행 로그 |
| 9 | staging 반영 검토 준비 | 진행 중 | 승인 완료 E2E, 예외 케이스, 환경값/롤백 경로 정리와 함께 실제 API 공백은 Baron SSO 이슈 명세로 분리 정리 | 수동 점검 체크리스트, API 보강 요청 초안 |
| 9-A | 직원검색/조직도 UI 정렬 보정 | 진행 중 | 선택 칩 강조, 칩 overflow 스크롤 탐색, 조직도 인원 정렬 규칙을 정책과 코드에 반영 | 개정 정책 문서, widget test, emulator 확인 |
| 9-B | 직원검색 규칙/프로필 표현 보강 | 진행 중 | 검색 범위 정책, 한글/전화번호 최소 입력 규칙, 프로필 사진 노출 가능 조건을 정책과 코드에 반영 | 개정 정책 문서, widget test, emulator 확인 |
| 9-C | 프로필 사진 식별자 매핑 검토 | 진행 중 | 네이버웍스 우선, Baron SSO `members[].id` 기반 UUID 파일명 2순위, 앱 기본 아바타 3순위 구조로 정책을 재정렬하고 파일 재배치/앱 연동 기준을 정리 | 매핑 정책 문서, UUID 매핑 CSV, 샘플 URL 검증 |
| 9-D | 입력기/구분 표식 보정 | 진행 중 | 직원검색 입력기의 한글 입력 친화 설정과 상하 영역 구분 표식을 정책과 코드에 반영 | 개정 정책 문서, widget test, emulator 확인 |
| 9-E | 초기 선택 scope 재정렬 | 진행 중 | 앱 첫 진입 시 회사급이 아니라 본인 팀 뱃지가 실제 선택 상태가 되도록 정책과 코드에 반영하고, 중앙 구분 아이콘은 제거 | 개정 정책 문서, widget test, emulator 확인 |
| 9-F | 공기계 USB 테스트 전환 | 진행 중 | emulator 기반 수동 검증의 반복 장애를 줄이기 위해 공기계 USB 연결, `adb reverse`, 실기기 env/preflight를 추가하고 수동 점검부터 안정화 | 공기계 정책 문서, device env 예시, preflight 스크립트, 수동 실행 결과 |
| 9-G | 독립형 실기기 서버 연동 전환 | 진행 중 | USB reverse 기반 로컬 실행과 별도로, 공기계/실사용 폰이 USB 없이도 staging 또는 production Baron API에 직접 붙어 동작할 수 있도록 공개 base URL, 인증 흐름, APK 실행 조건을 정리 | 독립 실행 체크리스트, 환경값 표, 실서버 APK 검증 절차 |
| 9-H | 레거시 전용 API 흔적 단계적 제거 | 진행 중 | `/api/v1/tdc114plus/...` 가정 path와 Baron 전용 DTO 흔적을 실제 Swagger path 매핑 기준으로 `유지/교체/제거` 분류하고, 기능 보존 테스트를 동반해 순차 제거 | 레거시 분류표, 대체 path 매핑, 기능 보존 테스트 결과 |
| 9-I | Baron SSO RP/PKCE 연결 | 진행 중 | 등록된 PKCE RP의 issuer, callback, client ID를 앱 환경에 반영하고 Hosted Login 완료 후 OIDC 세션 연결을 검증 | RP 설정표, 앱 링크 설정, callback 수신 및 토큰 교환 테스트 |
| 9-J | Headless 직접 호출 계약 정리 | 진행 중 | headless API 직접 호출은 앱 기본 흐름에서 제외하고, Baron SSO Hosted Login 내부 구현 참고사항으로 격하 | 정책 개정, 레거시 코드 제거 계획 |
| 9-K | 로그인 후 org-context credential 수신 준비 | 진행 중 | Baron SSO가 로그인 성공 후 조직도 API 연동 키를 내려줄 예정이므로 세션 모델/저장소/API client에 선택적 credential 구조를 준비하고, 미개발 기간에는 staging env fallback을 유지 | AuthSession 확장, credential 우선순위, fallback 테스트 |
| 10 | 빌드/배포 준비 | 대기 | Android debug APK, README, 잔여 이슈 정리 | APK 및 배포 준비 문서 |
## 3. 단계별 타임테이블
| 단계 | 상태 | 목표 | 주요 작업 | 완료 기준 |
| --- | --- | --- | --- | --- |
| Phase 0 | 완료 | 저장소와 개발환경 출발점 확보 | clone, 문서 이관, Flutter 실행 기반 구성 | 기본 프로젝트 동작 |
| Phase 1 | 완료 | Mock 기반 UI/상태 흐름 확보 | 로그인 화면, 직원검색, 조직도, 상세, 즐겨찾기 구성 | analyze/test 통과 |
| Phase 2 | 완료 | 초기 API 연동 골격 확보 | auth/directory/organization client, repository, session 저장 | 단위 테스트 통과 |
| Phase 3 | 진행 중 | 기본 로그인 흐름을 Hosted Login + PKCE 방식으로 전환 | authorization URL 생성, 외부 SSO 로그인, App Link callback, token 교환, 세션 저장 | PKCE 로그인 기본 흐름 코드 반영 |
| Phase 4 | 진행 중 | Swagger 기준 계약 재정렬 | 실제 사용 endpoint/DTO/에러 구조 재확정, 1차 사용 API/feature 매핑 문서화 | 계약 문서와 코드 구조 정렬 |
| Phase 5 | 진행 중 | feature별 분리형 구조 고정 | remote data source, repository interface, mock implementation 정리 | mock/real 전환 가능한 구조 확보 |
| Phase 6 | 완료 | 실제 API 응답 정합성 점검 | 로그인, directory, organization smoke 및 integration 확인 | 핵심 응답 필드 일치 확인 |
| Phase 7 | 진행 중 | staging 반영 검토 준비 | 승인 완료 E2E, 예외 케이스, 수동 체크리스트 정리, API 공백 이슈 분리 | staging 검토 가능 상태 및 API 보강 요청 초안 |
| Phase 7-A | 진행 중 | 직원검색/조직도 시인성 보정 | 선택 상태가 눈에 띄는 칩 표현, 스크롤 가능한 칩 탐색, 조직도 인원 정렬 규칙 반영 | 캡쳐 요청사항 반영 및 widget test |
| Phase 7-B | 진행 중 | 직원검색 규칙/프로필 표현 보강 | 검색 입력 최소 조건, 선택 범위 내 검색 원칙, 프로필 사진 노출 가능 구조 반영 | 정책 반영 및 emulator 확인 |
| Phase 7-C | 진행 중 | 프로필 사진 식별자 매핑 검토 | 네이버웍스 우선, Baron SSO `members[].id` 기반 UUID 파일명 이미지 2순위, 앱 기본 아바타 3순위 구조를 기준으로 실제 매핑 가능 조건과 파일 재배치/앱 연동 기준을 정리 | UUID 매핑 CSV, 샘플 URL 검증, 앱 공통 resolver 정리 |
| Phase 7-D | 진행 중 | 입력기/구분 표식 보정 | 직원검색 입력기의 한글 입력 친화 설정과 상하 영역 구분 표식 반영 | emulator 확인 및 사용성 점검 |
| Phase 7-E | 진행 중 | 초기 선택 scope 재정렬 | 첫 진입 시 본인 팀을 실제 선택 상태로 맞추고 불필요한 중앙 아이콘 제거 | emulator 확인 및 정책 일치 |
| Phase 7-F | 진행 중 | 공기계 USB 테스트 전환 | 실기기 연결 preflight, `adb reverse`, 실기기 env, 수동 실행 경로를 추가하고 emulator 경로는 fallback으로 보존 | 공기계에서 수동 post-login 점검 가능 |
| Phase 7-G | 진행 중 | USB 없는 독립형 실기기 테스트 전환 | 공기계/실폰이 로컬 PC 연결 없이도 staging 또는 production Baron API에 직접 붙도록 공개 API base URL, HTTPS, headless 승인 로그인, APK 실행값을 정리 | USB 분리 후에도 앱이 실서버 기준으로 로그인/직원검색 가능 |
| Phase 7-H | 진행 중 | 레거시 전용 API 흔적 단계적 제거 | 전용 API 가정 path와 Baron 종속 DTO를 `유지/교체/제거`로 분류하고, 각 단계마다 로그인/직원검색/조직도 기능 보존 테스트를 수행 | Swagger 기준 재매핑 후 기능 유지 상태 확보 |
| Phase 7-I | 진행 중 | 등록된 Baron SSO RP와 앱 연결 | PKCE 공개 클라이언트 설정, HTTPS callback 수신, state 검증, authorization code 교환을 Hosted Login callback에 연결 | 실기기에서 SSO 로그인 완료 후 앱 세션 생성 |
| Phase 7-J | 진행 중 | 로그인 후 조직도 API 키 수신 준비 | Baron SSO 로그인 성공 응답/후속 session 응답에서 org-context credential을 받을 수 있게 앱 모델을 확장하고, 미개발 동안 staging 고정 키 fallback을 유지 | session credential 우선, env fallback 보장 |
| Phase 8 | 대기 | 빌드/배포 준비 | APK, 실행 문서, 잔여 이슈 정리 | 배포 준비 산출물 확보 |
## 4. 현재 진행 상태
완료된 핵심 항목:
- Flutter 앱 기본 프로젝트 및 테스트 기반 구성
- 직원검색/조직도/상세/즐겨찾기 mock 화면 구축
- auth, directory, organization API client 및 repository 초안 구성
- 세션 저장/복원 흐름 구현
- Baron SSO Hosted Login + PKCE callback 수신 경로 반영
- 분리형 API 전환 정책 문서 수립
- 개발 정책, API 계약, 테스트 정책 개정
- 1차 사용 API 및 auth/directory/organization feature 매핑 문서화
진행 중 핵심 항목:
- Swagger 기준 실제 사용 API 목록 재정리
- Hosted Login + PKCE 기준 실제 응답 정합성 점검
- directory/organization 응답의 실제 데이터 정합성 검토
- feature별 remote/mock 구조 정리
- organization repository 추상화 추가 및 directory 직접 의존 완화
- auth repository의 기본 경로와 fallback 경로 역할 분리
- directory repository는 employees만, organization repository는 tenants만 담당하도록 1차 경계 분리
- organization 상태 재사용은 `org-context` 중심으로 유지하고 공유 orgchart UI 연결은 후속 단계로 분리
- `org-context` 전용 provider를 추가하고 실제 UI 연결은 후속 단계로 유지
- auth/directory 구현체 명칭을 `Remote...Repository`로 일반화
- 실제 API smoke와 Android emulator fallback integration smoke를 모두 통과했고, 당일 override 포트(`5561`) 기준 실행도 확인했다
- Android integration 실행기는 현재 Flutter 버전 기준 `flutter drive` + `integration_test` driver 조합으로 정렬 완료했다
- 조직 하위 subtree 응답 부재를 실제 API에서 확인했고, 앱 fallback 정책과 별도로 Baron SSO 보강 이슈를 문서화해 병행 진행한다
- 직원검색/조직도 화면에 대해 선택 칩 시인성, overflow 스크롤, 조직도 인원 정렬 우선순위 보정 요청을 추가 반영 중이다
- 직원검색 범위는 선택된 상단 뱃지 기준으로 유지하고, 최소 입력 규칙과 프로필 사진 표현 보강을 추가 반영 중이다
- 프로필 사진은 현재 `profileImageUrl` 필드가 있으면 표현 가능하지만, 사번 기반 로컬/원격 이미지 매핑은 직원 DTO에 사번 식별자가 없어 추가 검토가 필요하다
- 프로필 이미지 정책은 현재 `네이버웍스 우선 -> Baron SSO org-context UUID 파일명 이미지 -> 앱 기본 아바타` 기준으로 재정렬했다
- 2026-07-20 기준 `tdc114plus-auth /api/v1/profile-image` route를 복구/보강하고, 네이버웍스 사진 원본 URL을 앱에 직접 주지 않도록 `/api/v1/profile-image/naver-photo` 프록시를 추가했다
- 네이버웍스 `Location` 원본 URL은 앱이 직접 열 때 `400 Authentication failed`가 발생할 수 있으므로, 1순위 NAVER_WORKS 이미지는 중계서버 프록시를 통해 `image/jpeg` 바이너리로 제공한다
- 2026-07-20 기준 실기기에서 기존에 기본 이니셜로 떨어지던 네이버웍스 사진 보유 인원의 이미지 표시가 정상화된 것을 확인했다
- 같은 시점 서버 로그에서 `NAVER_WORKS``BARON_UUID_R2` source가 모두 확인되어 1순위/2순위 분기 동작을 확인했다
- 2026-07-15 기준 `org-context` 실응답에서 `members[].id`가 실제 UUID 형식으로 내려오는 것을 확인했다
- 2026-07-15 기준 가족사 전체 `org-context`와 기존 CSV를 대조해 `2457/2457` 전건 UUID 매핑 성공을 확인했다
- 관련 산출물로 `docs/references/profile_image_uuid_rename_candidates.csv`를 생성했다
- 2026-07-15 기준 네이버웍스 서비스 계정 토큰 발급과 실제 User/Profile API 호출을 확인했다
- 샘플 `khkang@samaneng.com``GET /users/{userId}/photo` 결과 `HTTP 404`로 무사진 케이스를 확인했다
- 샘플 `thlee3@samaneng.com``GET /users/{userId}/photo` 결과 `HTTP 302``Location` 헤더를 확인했다
- 2026-07-15 기준 UUID 파일명 공개 URL 샘플 2건이 `HTTP 200 image/jpeg`로 응답하는 것을 확인했다
- 기존 `tdc114plus-auth + PostgreSQL` profile-image fallback 경로는 기술 검증 자료로만 남기고, 현재 운영 기본안으로는 채택하지 않는다
- 현재 Phase 7-C의 중심 작업은 `앱 공통 resolver를 새 우선순위로 정리`하고 `302 -> UUID -> 기본 아바타` fallback 분기를 연결하는 일이다
- 2026-07-15 기준 Android 실기기 직원검색 화면에서 실제 프로필 사진 노출을 확인했다
- 현재 앱은 `tdc114plus-auth /api/v1/profile-image`를 우선 호출하고, 응답 실패 또는 미발견 시 UUID 공개 경로 fallback을 사용하는 보조 안전장치를 함께 둔다
- 2026-07-16 기준 실기기 로그인 후 직원검색이 다시 무너지는 핵심 원인은 `tdc114plus-auth` 5001 서버의 `org-context upstream self-recursion`이었다
- 원인은 `scripts/start-auth-server.sh``TDC114_AUTH_UPSTREAM_ORG_CONTEXT_API_BASE`를 env 파일 `source` 전에 읽던 순서 문제였고, 이 때문에 실행 중 `BARON_ORG_CONTEXT_BASE_URL``http://127.0.0.1:5001`로 잘못 설정되었다
- 2026-07-16 기준 해당 스크립트 순서를 수정하고 5001 재기동 후 실행 중 프로세스 환경값이 `BARON_ORG_CONTEXT_BASE_URL=https://sadmin.hmac.kr`로 반영된 것을 확인했다
- 위 복구 이후 실기기에서 `로그인 성공 -> 직원검색 목록 표시 성공`까지 다시 확인했다
- 직원검색 한글 입력은 코드상 차단 요소를 제거한 상태로 정렬하고, 상하 영역 구분 표식을 추가 반영 중이다
- 초기 진입 선택 상태는 본인 팀 뱃지가 실제 선택되도록 재정렬 중이며, 중앙 구분 아이콘은 제거 방향으로 반영 중이다
- Android emulator의 물리 키보드/ADB/portproxy 반복 장애가 확인되어, 공기계 USB 연결을 기본 수동 테스트 경로로 전환하는 작업을 시작한다
- 공기계 USB 연결 기준으로는 실데이터 화면까지 확인했지만, 현재 실행 모드는 `adb reverse + http://127.0.0.1:5000` 기반 로컬 Baron API 연결이므로 USB 분리 후 독립 동작은 아직 보장하지 않는다
- USB 없이 동작하는 실서버 APK 테스트로 가려면 `TDC114_API_BASE`를 staging 또는 production 공개 URL로 전환하고, headless 승인 로그인과 HTTPS 경로를 그 환경에서 다시 검증해야 한다
- 2026-07-08 기준 앱/스크립트는 `TDC114_AUTH_API_BASE`, `TDC114_DIRECTORY_API_BASE`, `TDC114_ORGANIZATION_API_BASE` 분리 주입을 지원하므로 `로그인은 staging`, `직원/조직 데이터는 production` 조합까지 실행 준비가 되어 있다
- 2026-07-10 기준 조직/직원 원본 참고 host는 staging `https://sadmin.hmac.kr`로 되돌린다. `admin.brsw.kr` production host는 현재 앱 개발 기준에서 우선 사용하지 않는다
- 2026-07-10 기준 Baron SSO 로그인 성공 시 조직도 API 호출용 ID/Secret을 함께 내려주는 기능은 아직 미개발로 보고, 앱은 해당 값을 받을 준비만 먼저 한다
- 해당 기능이 완성되기 전까지 조직/직원 데이터는 staging `org-context` endpoint와 로컬 비추적 env/Dart define의 고정 키 fallback으로 검증한다
- 따라서 USB 없는 독립형 실기기 최종 검증의 현재 외부 blocker는 `신규앱 public TDC114_API_BASE` 확정이다
- 2026-07-08 사용자 확인 기준으로는 `신규앱 public TDC114_API_BASE` 자체를 별도 `tdc114plus 전용 API host`로 찾는 방향이 아니라, Swagger에 공개된 Baron 기존 API path를 앱 데이터 소스로 직접 쓰는 방향으로 재정렬해야 한다
- 따라서 현재 진짜 blocker는 `별도 host 탐색`이 아니라 `Swagger 공개 path 중 무엇이 직원검색/조직도/로그인에 대응하는지 endpoint 단위로 재매핑`하는 일이다
- 그에 따라 현재 코드와 문서에 남아 있는 `/api/v1/tdc114plus/...` 전용 API 흔적은 레거시로 분류하고, 기능이 무너지지 않도록 테스트를 동반해 단계적으로 제거하는 것을 기본 작업원칙으로 추가한다
- 2026-07-19 기준 배포 관점의 기준 구조를 다시 정리한다
- `tdc114plus-auth`는 최종 배포 단위로 유지한다
- `baron-sso-tdc114plus-api`는 현재 로컬 개발/검증용 worktree로만 취급하고, 배포 시점의 직접 필수 구성으로 보지 않는다
- 최종적으로 신규앱은 Baron SSO 원본 `staging` 연동을 먼저 안정화하고, 이후 Baron SSO 원본 `production` 연동으로 승격하는 순서를 기본 정책으로 삼는다
- 따라서 지금부터의 정리 작업은 `무엇을 tdc114plus-auth에 남길지`, `무엇을 Baron SSO 원본에 의존할지`, `무엇을 로컬 전용 임시 자산으로 볼지`를 분리하는 방향으로 진행한다
대기 중 핵심 항목:
- staging 승인 완료 E2E 검증
- Android debug APK 빌드 및 실행 점검
- 운영 전 저장소/보안 저장 방식 재검토
## 5. 다음 작업
다음 작업은 아래 순서로 진행한다.
1. 앱 직원검색/조직도/상세 화면의 공통 resolver를 `네이버웍스 -> UUID 파일명 -> 기본 아바타` 순서로 정리한다.
2. 네이버웍스 `302`, `404` 실응답 규칙을 `tdc114plus-auth` 프록시와 UUID fallback 분기에 연결한 상태를 유지 검증한다.
3. 샘플 사용자 외에 `UUID 이미지 성공`, `UUID 이미지 없음`, `이메일 누락`, `DEFAULT 기본 아바타` 케이스를 추가 검증한다.
4. 먼저 5001 auth broker의 실행 중 upstream 값이 `https://sadmin.hmac.kr`로 유지되는지 재확인한다.
5. 실기기 검증 전 `adb reverse tcp:5001 tcp:5001`, `adb reverse tcp:5000 tcp:5000`이 유지되는지 확인한다.
6. 로그인 후 30초 이상 세션이 유지되는지 다시 확인한다.
7. 조직 하위 subtree API 보강 요청 이슈 초안을 정리하고 Baron SSO 개발자 검토용 명세를 확정한다.
8. Swagger 공개 path 중 직원검색/조직도/로그인에 대응하는 실제 endpoint를 표로 다시 정리한다.
9. 현재 코드의 `/api/v1/tdc114plus/...` 가정 path를 `유지`, `교체`, `폐기`로 분류한다.
10. `교체` 대상으로 분류된 path부터 대체 Swagger path/DTO를 코드와 mock에 반영한다.
11. 각 교체 단계마다 `로그인`, `직원검색`, `조직도`, `초기 선택 scope` 기능 보존 테스트를 수행한다.
12. `org-context` 응답을 기준으로 현재 앱 화면 요소와 모델 필드를 1:1 매핑한 판단서를 만든다.
13. staging 승인 완료 E2E와 예외 케이스 범위를 체크리스트로 구체화한다.
14. Hosted Login + PKCE 기준 수동 검증 절차를 Baron SSO RP 설정 기준으로 재정렬한다.
15. 검토 결과를 test log와 체크리스트에 반영한다.
16. USB reverse 기반 로컬 실행과 별도로, 실제 공개 path를 사용한 독립형 실기기 APK 검증 단계를 실행한다.
17. Baron SSO RP의 전체 Client ID를 `TDC114_OIDC_CLIENT_ID`로 주입하고 discovery 문서의 실제 endpoint와 일치하는지 확인한다.
18. `https://114.hmac.kr/auth/callback`이 앱으로 연결되는 HTTPS App Link 설정과 `assetlinks.json`을 검증한다.
19. callback의 `state`, authorization code, PKCE `code_verifier` 검증 및 token 교환을 구현한다.
20. AuthSession에 선택적 `orgContextCredential` 구조를 추가한다.
21. `org-context` API client가 `session credential -> staging env fallback` 순서로 인증값을 선택하도록 정리한다.
22. Baron SSO Hosted Login 시작부터 callback 수신, 세션 저장, 직원검색 진입까지 실기기 E2E를 수행한다.
23. 현재 `baron-sso-tdc114plus-api`에 남아 있는 기능을 `배포 필수`, `개발 중 임시`, `제거 가능`으로 분류한다.
24. `배포 필수` 기능 중 `tdc114plus-auth`로 흡수 가능한 항목과 Baron SSO 원본 `staging/prod` 의존으로 남겨야 할 항목을 구분한다.
25. 로컬 Baron worktree 없이도 신규앱이 배포 구조에서 동작할 수 있도록 최종 연동도와 점검표를 만든다.
## 5-B. 2026-07-15 Phase 7-C 연속성 메모
다음 작업 재개 시 우선 확인할 기준점은 아래와 같다.
- 2순위 프로필 이미지 식별자는 Baron SSO `org-context``members[].id`다.
- 2026-07-15 기준 `members[].id`가 실제 UUID 형식으로 내려오는 것을 실조회로 확인했다.
- 공개 이미지 prefix는 `https://baroncs.co.kr/employee_img/` 다.
- 기존 매핑 원본은 `docs/references/file_rename_hash_results.csv` 다.
- UUID 리네임 작업용 산출물은 `docs/references/profile_image_uuid_rename_candidates.csv` 다.
- 2026-07-15 기준 기존 CSV `2457`건은 가족사 전체 `org-context` UUID와 전건 매핑 성공했다.
- 2026-07-15 기준 UUID 파일명 공개 URL 샘플 2건은 `HTTP 200 image/jpeg` 응답을 확인했다.
- 2026-07-15 기준 네이버웍스 사진 API는 `khkang@samaneng.com`에서 `404`, `thlee3@samaneng.com`에서 `302`를 확인했다.
- 현재 운영 기본안은 `네이버웍스 -> UUID 파일명 이미지 -> 기본 아바타` 다.
- 기존 `tdc114plus-auth + PostgreSQL` profile-image fallback 경로는 보류된 대안으로만 남긴다.
- 2026-07-20 기준 네이버웍스 1순위 사진은 앱이 원본 `Location` URL을 직접 열지 않고 `tdc114plus-auth` 프록시를 통해 표시한다.
- 2026-07-20 기준 `tdc114plus-auth`와 앱 테스트에서 1순위/2순위/3순위 fallback 및 NAVERWORKS 프록시 바이너리 응답을 검증했다.
다음날 또는 네트워크 장애 후 재개 순서는 아래를 기본으로 한다.
1. 5001 health와 `adb reverse 5001/5000`을 먼저 확인한다.
2. 실기기에서 `NAVER_WORKS`, `BARON_UUID_R2`, `DEFAULT` source별 화면 검증을 확대한다.
3. 직원 상세/조직도/즐겨찾기 화면에서도 동일 이미지 규칙이 유지되는지 확인한다.
4. 필요 시 샘플 사용자 추가로 `{uuid}.jpg` 공개 URL 응답을 더 점검한다.
## 5-C. 2026-07-09 Baron SSO RP 확정값
| 항목 | 확정값/정책 |
| --- | --- |
| RP 유형 | PKCE 공개 클라이언트 |
| OIDC issuer | `https://sso.hmac.kr/oidc` |
| Discovery | `https://sso.hmac.kr/oidc/.well-known/openid-configuration` |
| Authorization endpoint | `https://sso.hmac.kr/oidc/oauth2/auth` |
| Token endpoint | `https://sso.hmac.kr/oidc/oauth2/token` |
| UserInfo endpoint | `https://sso.hmac.kr/oidc/userinfo` |
| Redirect URI | `https://114.hmac.kr/auth/callback` |
| Client ID | `39d6190d-72f6-4a58-a84f-cdc5ece3e8af`를 APK 기본 설정에 포함하고 환경별로 `TDC114_OIDC_CLIENT_ID` 재정의 가능 |
| Client Secret | 사용 금지. PKCE 앱에는 Client Secret이 없음 |
현재 반영 상태:
- Android callback host를 `114.hmac.kr`로 변경했다.
- callback 화면과 `/auth/callback` 앱 라우트는 수신 준비 상태다.
- OIDC issuer, redirect URI, Client ID 환경 주입 항목을 추가했다.
- authorization 요청 생성, PKCE verifier 보관, code 교환은 다음 구현 단계다.
- `114.hmac.kr` 서버의 Android App Link 위임 파일(`/.well-known/assetlinks.json`)이 준비되기 전에는 HTTPS 링크가 앱 대신 브라우저에서 열릴 수 있다.
- 2026-07-09 최신 debug APK를 공기계에 설치하고 callback URL을 실행했으며, Android 앱 선택창이 표시되는 것까지 확인했다.
- debug APK의 package/SHA-256 지문을 기준으로 `docs/references/assetlinks.debug.json`을 생성했다.
- `https://114.hmac.kr/.well-known/assetlinks.json` 배포는 임시 Windows 테스트 서버 방식으로 검증한다.
- 2026-07-09 임시 Windows 테스트 서버에서 `assetlinks.json`을 제공하고 외부 HTTPS HTTP 200/JSON 응답을 확인했다.
- 구형 공기계는 자동 App Link 상태가 `undefined`여서 테스트 기본 앱을 지정했으며, callback URL이 TDC114PLUS `.MainActivity`를 직접 열고 `test-code`를 수신하는 것까지 확인했다.
- RP 전체 Client ID를 APK 기본 설정에 반영했다.
- 다음 작업은 authorization 요청 생성과 PKCE code 교환 구현이다.
- 공식 headless API는 `private_key_jwt client_assertion`을 필수 요구하지만 등록 RP는 PKCE 공개 앱이므로, Flutter 앱의 기본 로그인 경로에서는 해당 API를 직접 호출하지 않는다.
- 표준 PKCE 생성, state 검증, token 교환 계층을 우선 구현한다.
## 5-D. Phase 7-C 프로필 사진 식별자 매핑 검토 초안
현재 신규앱은 프로필 사진 파일을 자체 보관하지 않고, 외부 공개 경로의 이미지를 화면에 표시하는 방향을 기본 전제로 둔다.
현재 채택안의 핵심은 아래와 같다.
- 앱은 더 이상 `이메일 @앞부분.jpg` 파일명을 직접 만들지 않는다.
- 앱은 해시 파일명도 직접 계산하지 않는다.
- 2순위 프로필 이미지는 Baron SSO 조직도 `members[].id`를 파일명으로 사용하는 UUID 이미지다.
- 공개 경로는 `https://baroncs.co.kr/employee_img/{uuid}.jpg` 규칙으로 고정한다.
- 1순위는 네이버웍스, 3순위는 앱 기본 아바타다.
### 1. 목표
- 내부 파일명 규칙을 앱과 URL에서 직접 노출하지 않는다.
- 기존 이미지 자산을 전면 재가공하지 않고도 단계적으로 재사용 가능하게 만든다.
- 직원검색, 조직도, 직원 상세 화면에서 동일한 규칙으로 프로필 이미지를 노출한다.
- 이미지가 없거나 매핑이 실패해도 기본 아바타로 안전하게 fallback 한다.
### 2. 외부서버(테이블) 측 단계별 작업 초안
1. 기존 이미지 파일을 Baron UUID 기준 `[uuid].jpg` 로 변경한다.
2. 변경된 파일을 `employee_img/` 하위에 재배치한다.
3. 샘플 사용자 여러 건의 공개 URL 응답을 검증한다.
4. 네이버웍스 1순위 경로와 UUID 이미지 2순위 경로의 fallback 순서를 앱과 서버 역할에 맞게 반영한다.
5. 이메일 변경이나 인사 이동이 생겨도 UUID 기준 파일명 정책이 유지되는지 운영 절차를 정리한다.
### 3. 신규앱 측 단계별 작업 초안
1. 현재 직원 DTO와 `org-context` 응답에서 `member.id`를 안정적으로 확보할 수 있는지 유지 확인한다.
2. 앱은 `member.id` 또는 그와 동등한 Baron UUID를 이용해 2순위 이미지 URL을 만든다.
2026-07-15 기준 앱 공통 resolver에서 기존 `GET /api/v1/profile-image` fallback 의존을 제거하고, `profileImageUrl -> https://baroncs.co.kr/employee_img/{uuid}.jpg -> 기본 아바타` 규칙으로 정리했다.
3. 앱은 더 이상 이메일 기반 또는 해시 기반 파일명을 만들지 않는다.
4. 화면별 fallback 처리
이미지 조회 실패, key 누락, 외부서버 404, timeout 상황에서는 기본 아바타를 노출한다.
프로필 사진 실패 때문에 직원검색/조직도 본문 데이터가 깨지거나 로딩이 멈추지 않도록 분리 처리한다.
5. 캐시 및 placeholder 정책 반영
검색 결과 목록과 조직도는 동일 이미지가 반복 노출될 수 있으므로, 앱 이미지 캐시와 placeholder 표시 규칙을 함께 정리한다.
6. 테스트 시나리오 추가
최소 검증 항목은 아래와 같다.
- 네이버웍스 사진이 있을 때 1순위 이미지 노출
- UUID 이미지가 있을 때 2순위 이미지 노출
- UUID 이미지가 없을 때 기본 이미지 노출
- 응답 지연 또는 timeout 시 화면 본문 기능 유지
- 잘못된 사용자 이미지가 다른 직원에게 매핑되지 않는지 확인
### 4. 현재 판단 기준
- `이메일 @앞부분.jpg` 직접 조합 방식은 더 이상 채택하지 않는다.
- 현재 기본안은 Baron SSO `members[].id` 기반 UUID 파일명 방식이다.
- 가족사 전체 매핑 검증 결과 `2457/2457` 전건 대응이 확인됐으므로, 파일 재배치만 완료되면 운영 기준으로 사용할 수 있다.
- 따라서 현재 Phase 7-C의 실질적 핵심은 `UUID 파일 실배치 완료`, `앱 공통 resolver 재정리`, 그리고 `tdc114plus-auth /api/v1/profile-image``네이버웍스 -> UUID 이미지 -> 기본 아바타` 순서로 재정리한 뒤 실제 기동 검증까지 마무리하는 것이다.
### 5. 후속 확인 필요 항목
- 네이버웍스 개발자 계정 기준 실제 `GET /users/{userId}/photo` 응답 규칙
- UUID 파일 재배치 완료 후 공개 URL 반영 시점
- 퇴사자/미등록자 이미지 처리 기준
- Baron UUID가 장기적으로 변경되지 않는지 운영 측 확인
## 5-A. 공기계 USB 테스트 전환 작업 순서
공기계 전환은 아래 순서로 진행한다. 예상 소요는 정책/스크립트 보강 20~30분, 실제 단말 연결 검증 10~20분이다.
1. 정책과 타임테이블에 공기계 USB 테스트 전환 단계를 먼저 반영한다.
2. 실기기 전용 env 예시(`scripts/android-device.env.example`)를 추가한다.
3. 실기기 preflight 스크립트(`scripts/check-android-device-env.sh`)를 추가한다.
4. Windows ADB server 공유 방식에서 물리 단말이 `device`로 보이는지 확인한다.
5. `adb reverse tcp:5000 tcp:5000`을 우선 적용하고, 실패하면 PC LAN IP 방식으로 fallback 한다.
6. `manual-postlogin-run.sh`로 수동 기능점검을 먼저 안정화한다.
7. 수동 점검이 통과하면 `integration_tests.sh`를 공기계 target으로 확장한다.
8. 검증 결과를 test log와 관련 정책 문서에 기록한다.
## 5-B. USB 없는 독립형 실기기 테스트 전환 작업 순서
공기계에 앱이 설치되어 있어도, 현재처럼 `TDC114_API_BASE=http://127.0.0.1:5000`을 쓰는 실행 모드라면 USB를 뽑는 순간 로컬 Baron API 경로가 끊긴다. USB 없이도 동작하려면 아래 단계가 필요하다. 예상 소요는 환경 정리 20~30분, 실서버 APK 1차 검증 30~60분이다.
1. 실행 모드를 둘로 분리해 문서에 고정한다.
- `로컬 개발 모드`: `adb reverse + http://127.0.0.1:5000`
- `독립 실행 모드`: `https://<staging-or-production-host>`
2. staging 또는 production 중 실제 실기기 검증에 사용할 Baron API base URL을 확정한다.
3. 해당 환경에서 `headless phone-login`, `link/poll`, `integrations/org-context`, `public/orgchart`가 모두 공개 HTTPS 경로에서 정상 동작하는지 확인한다.
4. 실기기 APK 전용 env 파일을 분리한다.
- 예: `scripts/.env.android-device.staging.local`
- 예: `scripts/.env.android-device.production.local`
5. 로그인과 데이터 host를 분리해야 하면 아래 override를 함께 준비한다.
- `TDC114_AUTH_API_BASE`
- `TDC114_DIRECTORY_API_BASE`
- `TDC114_ORGANIZATION_API_BASE`
6. `manual-postlogin-run.sh` 또는 별도 실행 스크립트에서 실서버 URL과 필요한 override를 주입해 APK를 다시 설치한다.
7. USB 연결 상태에서 1차 설치와 실행만 수행하고, 앱이 실행된 뒤 USB를 분리한다.
8. USB 분리 후에도 Wi-Fi만으로 로그인, 직원검색, 조직도 조회가 유지되는지 확인한다.
9. 실사용 폰 설치 전에는 아래를 추가 확인한다.
- 앱 아이콘/앱명/버전 표기
- cleartext 미사용 여부
- staging/production 혼선이 없는지
- headless 승인 로그인 수신 채널(문자/메일) 실제 도달 여부
10. 실사용 폰 테스트 단계에서는 APK 전달, 설치, 로그인 승인, 검색/조직도/전화/문자 동작까지 한 번의 시나리오로 점검한다.
## 6. 작업 기록 원칙
- 새 작업이 생기면 본 문서의 `작업진행 절차` 또는 `다음 작업`에 반영한다.
- 완료된 작업은 상태를 갱신하고 산출물 위치를 기록한다.
- 중요 실행 결과는 `docs/test-logs/` 또는 `docs/daily-issues/`에 남긴다.
- 과거 세부 이력은 보존하되, 현재 실행 기준은 본 문서의 최신 Phase 상태를 따른다.
@@ -1,7 +1,7 @@
# tdc114plus-auth 저장소 운영 정책
작성일: 2026-07-15
상태: v1.0
상태: v1.1
목적: `tdc114plus-auth` 중계서버의 공식 Gitea 저장소 위치와 저장소 경계 운영 기준을 고정한다.
@@ -16,6 +16,9 @@
- `tdc114plus-auth`는 별도 Gitea 저장소로 관리한다.
- `tdc114plus` 앱 저장소 안에 서버 본체 코드를 함께 두지 않는다.
- `baron-sso` 저장소 안에도 흡수하지 않는다.
- 최종 배포 기준에서도 `tdc114plus-auth`는 독립 배포 단위로 유지한다.
- 다만 현재 로컬 개발에 쓰는 `baron-sso-tdc114plus-api` worktree는 최종 배포 필수 구성으로 보지 않는다.
- 최종 운영 구조는 Baron SSO 원본 `staging` 연동 안정화 후 Baron SSO 원본 `production` 연동으로 승격하는 방향을 기본값으로 삼는다.
공식 저장소:
@@ -186,6 +189,25 @@ ops(staging): adjust auth broker env defaults
예를 들어 `scripts/start-auth-server.sh``tdc114plus-auth` worktree 경로를 참조하는 보조 도구로 유지할 수 있지만, 서버 구현 본체는 별도 저장소에 있어야 한다.
## 8-A. 로컬 Baron worktree와의 관계
현재 개발 과정에서는 아래 세 축이 함께 보일 수 있다.
- `tdc114plus`
- `tdc114plus-auth`
- `baron-sso-tdc114plus-api`
하지만 이 중 최종 배포 직접 대상은 아래 두 축으로 본다.
- `tdc114plus`
- `tdc114plus-auth`
정리 원칙:
- `baron-sso-tdc114plus-api`는 개발 중 API 확인, 임시 연동, 로컬 재현을 위한 worktree로 본다.
- 배포 직전에는 `tdc114plus-auth`가 어떤 기능을 계속 직접 수행해야 하는지와, 어떤 기능이 Baron SSO 원본 `staging/prod` 의존으로 남는지를 명확히 분리해야 한다.
- 로컬 Baron worktree가 꺼져도 배포 구조 자체에는 영향이 없도록 정리하는 것이 목표다.
## 9. 최종 판단
- `tdc114plus-auth`는 별도 저장소가 필요한 독립 서비스다.
@@ -0,0 +1,192 @@
# tdc114plus 분리형 API 전환 정책
작성일: 2026-07-07
상태: v1.3
목적: `tdc114plus` Flutter 앱을 Baron SSO 백엔드 소스 직접 의존 방식에서 분리하고, 공식 API 인터페이스 중심 구조로 전환하기 위한 작업 원칙과 단계별 진행 순서를 고정한다.
기준 인터페이스:
- Swagger/API Docs: `https://sadmin.hmac.kr/api/docs#/`
운영 전환 메모:
- 2026-07-10 팀장 지시 기준으로 Baron SSO 원본 참고 API 기준은 production(`admin.brsw.kr`)이 아니라 staging(`sadmin.hmac.kr`)으로 다시 본다.
- 실제 배포 전까지 신규앱에서 사용하는 Baron SSO API 참고 기준은 staging Swagger(`https://sadmin.hmac.kr/api/docs#/`)다.
- 조직/직원 데이터 검증은 staging `GET https://sadmin.hmac.kr/api/v1/integrations/org-context`를 우선 사용한다.
- Baron SSO가 로그인 성공 후 조직도 API 연동 키를 내려주는 기능은 아직 미개발이므로, 앱은 받을 준비를 하되 현재 개발/검증은 로컬 비추적 env/Dart define의 staging 고정 키 fallback으로 진행한다.
- 운영 `CLIENT ID`, `X-Baron-Key-Secret` 실제 값은 tracked 문서나 tracked 앱 코드에 넣지 않고 로컬 비추적 `.env`에만 저장한다.
관련 문서:
- `docs/00_policy_tdc114plus_development_2026-07-02.md`
- `docs/00_contract_tdc114plus_api_2026-07-02.md`
- `docs/00_guide_tdc114plus_work_progress_timetable_2026-07-02.md`
- `docs/00_policy_tdc114plus_screen_feature_2026-07-07.md`
## 1. 최우선 원칙
- 신규 앱은 Baron SSO에 추가되는 별도 `RP(Relying Party)`로 간주한다.
- 신규 앱은 Baron SSO 백엔드 소스를 직접 수정해서 맞추는 방식으로 개발하지 않는다.
- 앱은 Swagger에 정의된 공식 Request/Response 계약을 기준으로만 통신 계층을 설계한다.
- 백엔드 구현 완료 여부와 무관하게 Flutter 앱은 mock 데이터와 repository 추상화로 독립 개발 가능해야 한다.
- 기존 코드가 동작하더라도 Swagger 계약과 다르면 계약 기준으로 재정렬한다.
- Baron SSO 관련 명칭, 경로, DTO가 앱 내부에 과도하게 박혀 있으면 점진적으로 일반화한다.
- 인증 기본 원칙은 `Baron SSO Hosted Login + OIDC Authorization Code + PKCE`다.
- Flutter 앱은 휴대폰번호/문자/메일 인증을 직접 처리하는 headless API 클라이언트가 아니라, Baron SSO 인증 URL을 열고 callback의 authorization code를 token으로 교환하는 공개 RP다.
- 휴대폰번호 입력과 링크 발송/승인 처리는 Baron SSO Hosted Login 화면과 서버 내부 구현으로 둔다.
- Swagger Auth 섹션의 `phone-login`, `headless`, `enchanted-link`, `sms`, `qr` 계열 API는 Baron SSO 화면/서버 내부 구현 참고사항으로 분류하고 앱 기본 로그인 구현에서 직접 조합하지 않는다.
- 기존 `선진행 후확인` 방식은 신규 앱의 기본 로그인 정책으로 채택하지 않는다.
- 현재 코드와 문서에 남아 있는 `/api/v1/tdc114plus/...` 전용 API 가정은 레거시 흔적으로 분류한다.
- 레거시 흔적 제거는 한 번에 삭제하지 않고 `실제 사용처 확인 -> 대체 Swagger path 매핑 -> mock/real 테스트 통과 -> 제거` 순서로 진행한다.
- 레거시 제거 과정에서 기능이 무너지지 않도록 기존 화면 동작, 로그인 흐름, 검색/조직 표시를 단계별로 검증한다.
## 2. 판단 결론
- 개발환경은 새로 갈아엎지 않는다.
- 기존 Flutter 프로젝트 뼈대는 유지한다.
- 다만 API 계층, 모델 계층, 환경설정, 테스트 기준은 분리 아키텍처에 맞게 중간 규모로 재정비한다.
즉, 이번 작업은 `재시작`이 아니라 `구조 개편형 마이그레이션`으로 본다.
## 3. 작업 범위
포함:
- Swagger 기준 API 목록 재정리
- RP 관점의 인증 흐름 정리
- Request/Response DTO 정리
- API client/service/repository 계층 정리
- mock 구현 및 테스트 데이터 정비
- 화면이 repository interface만 의존하도록 연결 정리
- 환경별 base URL 및 인증 헤더 주입 방식 정리
- 문서/테스트/개발 순서 정리
제외:
- Baron SSO 운영 백엔드 내부 로직 직접 수정
- Swagger에 없는 비공식 응답 구조 전제 개발
- 화면 요구사항과 무관한 대규모 UI 재설계
- 1차 범위 밖 기능 추가
## 4. 단계별 진행 순서
### Phase 1. 계약 기준 고정
작업:
- Swagger에서 신규 앱에 실제 필요한 endpoint만 1차 사용 목록으로 확정한다.
- 신규 앱 RP 등록/식별에 필요한 인증 전제와 로그인 시작점을 함께 정리한다.
- 각 endpoint별 method, path, request, response, error 형식을 앱 기준 표로 정리한다.
- 기존 내부 문서와 Swagger가 다르면 Swagger를 우선 기준으로 명시한다.
완료 기준:
- 앱에서 사용할 API 목록과 필수 필드가 문서로 고정되어 있다.
### Phase 2. 앱 내부 의존성 분리 설계
작업:
- feature별로 `remote data source -> repository interface -> UI` 흐름을 고정한다.
- 특정 백엔드 구현체 이름이 드러나는 타입명은 일반화 대상 목록으로 분류한다.
- 인증, 직원검색, 조직도, 즐겨찾기 중 실제 API 의존 기능과 로컬 기능을 분리한다.
완료 기준:
- 어떤 레이어가 Swagger 계약에 직접 의존하고, 어떤 레이어가 추상화에 의존하는지 구조가 정리되어 있다.
### Phase 3. DTO 및 API 클라이언트 정렬
작업:
- 현재 Dart 모델을 Swagger 응답 구조 기준으로 재검토한다.
- endpoint별 request/response DTO를 feature 단위로 정리한다.
- 공통 에러 모델, 타임아웃, 인증 헤더, base URL 처리 방식을 통일한다.
완료 기준:
- 각 feature의 API 호출 코드가 Swagger 계약과 1:1로 대응된다.
### Phase 4. Repository 추상화 및 Mock 우선 개발
작업:
- repository interface를 기준으로 remote/mock 구현체를 분리한다.
- 백엔드 미구현 또는 스펙 검증 전 단계에서는 mock repository로 화면 개발이 가능하도록 유지한다.
- widget test와 unit test가 실제 네트워크 없이도 핵심 흐름을 검증하도록 구성한다.
완료 기준:
- API가 불완전해도 화면 개발과 테스트가 계속 가능하다.
### Phase 5. 실제 API 연결
작업:
- 환경값으로 Swagger 대상 base URL을 주입한다.
- mock 구현을 유지한 채 remote 구현을 교체 가능하게 연결한다.
- 로그인, 직원검색, 조직도 등 우선 기능부터 실제 응답 정합성을 점검한다.
- 로그인은 `authorization URL 생성 -> 외부 Hosted Login 완료 -> App Link callback 수신 -> state 검증 -> PKCE token 교환 -> session 저장` 완료 기준으로 본다.
완료 기준:
- 동일한 UI가 mock/real repository 전환만으로 동작한다.
### Phase 6. 정리 및 고정
작업:
- Baron SSO 직접 의존 흔적, 임시 fallback, 오래된 계약 문구를 문서와 코드에서 정리한다.
- `/api/v1/tdc114plus/...`처럼 전용 API를 전제한 레거시 경로는 `유지`, `교체`, `제거`로 분류한 뒤 순차적으로 없앤다.
- 각 제거 단계마다 최소한 `로그인`, `직원검색`, `조직 탐색`, `조직도` 동작 확인을 먼저 수행한다.
- 테스트 기준과 수동 점검 순서를 갱신한다.
- 이후 신규 기능도 같은 패턴으로 추가하도록 개발 규칙을 고정한다.
완료 기준:
- 팀이 같은 방식으로 후속 기능을 이어서 개발할 수 있다.
## 5. 실제 실행 우선순위
가장 먼저 할 일:
1. Swagger 기준 1차 사용 API 목록 확정
2. 현재 코드의 API/DTO/repository 구조와 Swagger 차이점 목록화
3. 차이가 큰 feature부터 DTO와 repository interface 정리
4. mock 구현 유지 상태에서 remote 구현 교체
5. 실제 API smoke 및 화면 검증
## 6. 수정 방식 원칙
- 한 번에 전체 기능을 갈아엎지 않는다.
- feature 단위로 `계약 확인 -> DTO 정리 -> repository 정리 -> mock 유지 -> UI 연결` 순서로 바꾼다.
- 레거시 endpoint 제거는 `대체 endpoint 반영 -> 테스트 통과 -> 실제 화면 확인` 이후에만 진행한다.
- 화면 코드가 API 응답 JSON 구조를 직접 해석하는 형태는 금지한다.
- UI에서 HTTP, header, endpoint path를 직접 다루지 않는다.
- mock 데이터는 임시 코드가 아니라 공식 개발 수단으로 유지한다.
## 7. 문서 반영 원칙
- Swagger 기준 변경사항이 생기면 문서와 코드 중 문서를 먼저 갱신한다.
- 새 endpoint를 쓰기 시작하면 request/response 예시와 필수 필드를 문서에 남긴다.
- 진행 상태가 바뀌면 `docs/00_guide_tdc114plus_work_progress_timetable_2026-07-02.md`를 함께 갱신한다.
## 8. 이번 저장소에서의 즉시 적용 해석
현재 저장소는 아래 판단으로 진행한다.
- Flutter 앱 기본 프로젝트와 테스트 기반은 유지한다.
- 기존 `auth`, `directory`, `organization`, `favorites` 구조는 재사용한다.
- `BaronSso...`처럼 구현체 이름에 종속된 부분은 점진적으로 일반화한다.
- 기존 Baron SSO 전용 계약 문서는 유지하되, 앞으로는 Swagger 기준 문서가 상위 실행 기준이 된다.
- 앞으로의 구현 순서는 이 문서의 Phase 순서를 따른다.
## 9. 다음 작업 시작점
다음 작업은 아래 순서로 시작한다.
1. Swagger 기준 1차 사용 API를 문서로 고정한다.
2. 현재 Flutter 코드에서 그 API를 쓰는 feature를 매핑한다.
3. feature별 차이점과 수정 우선순위를 정리한다.
4. 우선순위 1 feature부터 코드 개편을 시작한다.
@@ -0,0 +1,121 @@
# tdc114plus 개발 정책
작성일: 2026-07-02
최종 개정일: 2026-07-10
상태: v2.2
목적: `tdc114plus` 개발 시 Baron SSO 연동 원칙, Flutter 앱 구조, 문서 우선순위, 테스트 및 협업 기준을 고정한다.
상위 기준 문서:
- `docs/00_policy_tdc114plus_decoupled_api_migration_2026-07-07.md`
## 1. 서비스 및 인증 전제
- `tdc114plus` 신규 앱은 Baron SSO에 추가되는 별도 `RP(Relying Party)`다.
- 앱 인증은 Baron SSO가 제공하는 공식 인증 절차를 소비하는 방식으로 설계한다.
- 신규 앱의 기본 로그인 방식은 `Baron SSO Hosted Login + OIDC Authorization Code + PKCE`다.
- 신규 앱은 Baron SSO에 등록된 `PKCE 공개 클라이언트 RP`이며 Client Secret을 앱 코드, APK, 환경 파일에 저장하지 않는다.
- `private_key_jwt` 서명용 개인키도 공개 모바일 APK에 포함하지 않는다.
- Flutter 앱은 Baron headless API(`/api/v1/auth/headless/...`)를 직접 호출하지 않는다.
- 휴대폰번호 입력, 문자/메일 링크 발송, 링크 승인 처리는 Baron SSO Hosted Login 화면과 Baron SSO 서버 내부 책임으로 본다.
- RP의 OIDC issuer는 `https://sso.hmac.kr/oidc`, 인증 callback은 `https://114.hmac.kr/auth/callback`을 기준으로 한다.
- 앱은 authorization 요청 전 PKCE `code_verifier`, `state`, `nonce`를 생성/보관하고, callback에서 `state` 검증 후 `code_verifier`로 authorization code를 교환한다.
- 전체 Client ID `39d6190d-72f6-4a58-a84f-cdc5ece3e8af`는 공개 RP 식별자이므로 APK 기본 설정에 포함한다.
- 환경별 RP가 달라질 경우 `TDC114_OIDC_CLIENT_ID` Dart define으로 기본값을 재정의할 수 있다.
- 기본 로그인 순서는 `앱 -> Baron SSO 인증 URL 열기 -> Hosted Login 화면에서 휴대폰번호 입력 -> 문자/메일 링크 인증 -> https://114.hmac.kr/auth/callback -> 앱 복귀 -> code를 token으로 교환`이다.
- 로그인 성공 후 Baron SSO가 조직도 API 호출에 필요한 연동 키 묶음을 전달하는 것을 최종 계약으로 준비한다.
- 해당 Baron SSO 기능이 미개발인 동안에는 staging `org-context` 검증을 위해 로컬 비추적 env/Dart define에만 고정 키를 둘 수 있다.
- 이 임시 고정 키는 tracked 문서, tracked 소스, 운영 APK 기본값에 넣지 않는다.
- 기존 Baron SSO의 `선진행 후확인` 방식은 신규 앱의 기본 로그인 정책으로 사용하지 않는다.
- 기존 `phone-login`이 남아 있더라도 개발용 fallback 또는 제한적 호환 경로로만 본다.
## 2. Baron SSO 연동 원칙
- 신규 앱은 Baron SSO 백엔드 소스를 직접 수정해서 맞추는 방식으로 개발하지 않는다.
- 앱은 Swagger에 정의된 공식 Request/Response 계약을 기준으로만 통신 계층을 설계한다.
- 실제 배포 전까지 앱이 참고하고 검증할 Baron SSO API 문서는 staging `https://sadmin.hmac.kr/api/docs#/`를 기준으로 한다.
- 조직/직원 데이터 API는 staging `GET https://sadmin.hmac.kr/api/v1/integrations/org-context`를 우선 기준으로 검증한다.
- Baron SSO 내부 구현은 참고 대상일 뿐, 앱의 상위 기준은 공식 인터페이스 문서다.
- 기존 API 응답을 앱 요구사항에 맞게 임의로 바꾸는 것을 기본 전제로 삼지 않는다.
- Baron SSO 관련 명칭, 경로, DTO가 앱 코드에 과도하게 박혀 있으면 점진적으로 일반화한다.
- Baron SSO backend/orgFront 변경과 `tdc114plus` Flutter 앱 변경은 저장소와 커밋을 분리한다.
## 3. 인터페이스 중심 개발 원칙
- 프론트엔드와 백엔드는 API 계약을 먼저 고정한 뒤 병행 개발한다.
- Flutter 앱은 `request/response DTO`, `API client`, `repository interface`, `UI`를 분리한다.
- UI는 repository interface만 의존해야 하며, HTTP 세부사항을 직접 다루지 않는다.
- 화면 코드가 JSON 응답 구조를 직접 해석하는 형태는 금지한다.
- 백엔드 구현이 완료되지 않았더라도 mock 데이터와 mock repository로 화면 개발이 가능해야 한다.
- API 계약이 바뀌면 문서, DTO, service, repository, test를 함께 갱신한다.
## 4. Flutter 공통 구현 원칙
- 공통 Flutter 코드는 처음부터 Android/iOS 모두를 고려해 작성한다.
- 화면, 상태관리, API client, repository, model은 플랫폼 공통 코드로 우선 설계한다.
- 플랫폼별 차이가 있는 기능은 공통 interface를 먼저 만들고 Android/iOS 구현체를 분리한다.
- 인증 UI와 상태관리는 `SSO 로그인 시작 -> 외부 Hosted Login -> App Link callback -> state 검증 -> token 교환 -> 세션 저장` 흐름을 기준으로 설계한다.
- Flutter 앱은 승인 링크 자체를 생성하거나 RP 비밀값, `client_assertion`, 개인키를 보관하지 않는다.
- 화면 UX, 기본값, 조직 탐색 규칙은 `docs/00_policy_tdc114plus_screen_feature_2026-07-07.md`를 기준으로 삼는다.
- 공지사항, 전자결재, 수신전화식별, 수신팝업은 1차 범위에서 보류한다.
- 1차 범위는 직원검색, 전화번호검색, 가족사 필터, 조직도, 직원목록, 전화걸기, 문자보내기, 즐겨찾기에 집중한다.
## 5. 플랫폼별 구현 원칙
- 플랫폼별 네이티브 기능은 Android에서 먼저 PoC를 완성한 뒤 iOS로 확장한다.
- iOS를 지나치게 늦게 검증하지 않는다.
- 푸시, 생체 인증, 보안 저장소, bridge는 공통 인터페이스와 플랫폼 구현체를 분리한다.
- 운영 배포 전에는 Android/iOS 모두 동일한 보안 기준을 통과해야 한다.
## 6. 개발 방식
- 작업은 `계약 확인 -> DTO 정리 -> repository 정리 -> mock 유지 -> UI 연결 -> 실제 API 검증` 순서로 진행한다.
- 한 번에 전체 기능을 갈아엎지 않고 feature 단위로 점진 개편한다.
- mock 데이터는 임시 코드가 아니라 병행 개발을 위한 공식 수단으로 유지한다.
- 실제 API 연결 전에도 widget test와 unit test가 가능한 구조를 우선 만든다.
- 구현체 이름이 강하게 박힌 타입명은 점진적으로 일반화한다.
## 7. 검증 원칙
Flutter 앱 변경 시 최소 검증:
```bash
./scripts/flutter-docker.sh analyze
./scripts/flutter-docker.sh test
```
상세 테스트 기준은 아래 문서를 따른다.
- `docs/00_policy_tdc114plus_testing_2026-07-02.md`
실제 API 연동 검증은 아래 기준을 따른다.
- Swagger 계약과 DTO 필드 일치 확인
- Hosted Login authorization URL 생성, App Link callback 수신, `state` 검증, PKCE token 교환, 세션 저장, 로그인 후 첫 화면 진입 확인
- mock 경로와 real API 경로가 동일한 UI 흐름을 유지하는지 확인
## 8. 질문 및 확인 원칙
작업 진행 중 판단이 필요하면 아래 순서로 진행한다.
1. 관련 정책 문서를 먼저 확인한다.
2. 문서 기준으로 처리 가능한 것은 바로 진행한다.
3. 정책 간 충돌, 해석 불명확, 보안 영향, 배포 영향이 있는 경우에만 질문한다.
4. 질문 시에는 확인한 문서, 판단 포인트, 선택지, 권장안을 함께 정리한다.
5. 새로운 결정이 내려지면 관련 문서와 타임테이블을 함께 갱신한다.
## 9. 문서 우선순위
개발 중 판단 기준은 아래 순서로 적용한다.
1. `docs/00_policy_tdc114plus_decoupled_api_migration_2026-07-07.md`
2. `docs/00_policy_tdc114plus_development_2026-07-02.md`
3. `docs/00_contract_tdc114plus_api_2026-07-02.md`
4. `docs/00_policy_tdc114plus_screen_feature_2026-07-07.md`
5. `docs/00_policy_tdc114plus_testing_2026-07-02.md`
6. `docs/00_guide_tdc114plus_work_progress_timetable_2026-07-02.md`
7. `docs/guide_baron_sso_reference_source_2026-07-02.md`
8. `docs/references/baron-safe-policies/`
참고 문서는 배경자료이며, 직접 정책보다 우선하지 않는다.
@@ -0,0 +1,250 @@
# tdc114plus 화면별/기능별 정책
작성일: 2026-07-07
상태: v1.3
목적: `tdc114plus` 앱의 주요 화면과 기능이 어떤 규칙으로 동작해야 하는지 화면 단위로 고정한다. 구현 변경 시 이 문서를 기준으로 화면 UX와 상태 규칙을 판단한다.
운영 원칙:
- 앱 화면/기능을 수정하기 전에는 반드시 이 문서를 먼저 확인한다.
- 수정 요청이 이 문서의 정책과 일치하는지 먼저 비교한다.
- 문서 정책과 충돌하는 작업이 필요하면, 충돌 항목과 변경 영향 범위를 사용자에게 상세히 설명하고 명시 허락을 받은 뒤 진행한다.
- 정책 변경이 확정된 뒤 구현을 바꾸는 경우, 정책 문서를 먼저 또는 같은 작업 단위 안에서 함께 갱신한다.
- APK 빌드/실기기 설치는 사용자의 명시 허락 후 진행한다.
관련 문서:
- `docs/00_policy_tdc114plus_development_2026-07-02.md`
- `docs/00_contract_tdc114plus_api_2026-07-02.md`
- `docs/policy_android_app_install_execution_2026-07-06.md`
## 1. 공통 원칙
- Baron SSO 로그인/세션 결과를 임의로 변형하지 않는다.
- 가족사/조직 탐색은 Baron SSO `tenant` 계층을 기준으로 진행한다.
- 직원검색 기본 첫 화면은 `본인 회사급 범위`를 기준으로 한다.
- 사용자가 명시적으로 조직 탐색을 시작했을 때만 `전체 -> 회사 -> 하위조직 -> 개인` 단계형 탐색으로 전환한다.
- 실제 API가 화면 정책을 충족하지 못하는 경우, fallback 동작을 문서화하고 필요한 API 보강 이슈를 별도로 작성해 병행 진행한다.
- 화면 정책이 바뀌면 widget test와 정책 문서를 함께 갱신한다.
## 2. AuthGate 화면 정책
대상:
- 앱 첫 진입
- 저장 세션 확인
규칙:
- 저장된 유효 세션이 있으면 `직원검색`으로 이동한다.
- 저장 세션이 없으면 `Baron SSO 로그인` 화면으로 이동한다.
- smoke/manual 실행에서 preauth session seed가 있으면 해당 세션을 그대로 사용한다.
## 3. 로그인 화면 정책
대상:
- `Baron SSO 로그인`
규칙:
- 신규 앱 기본 로그인 UX는 `전화번호 입력 -> 바론SSO 로그인 링크 발송 -> 문자 링크 클릭 -> 승인 완료 -> 앱으로 복귀 -> 앱 진입`이다.
- 로그인 화면은 앱 내부에 전화번호 입력창을 제공한다.
- 로그인 화면은 Baron SSO 화면과 유사한 어두운 카드형 디자인을 사용한다.
- 로그인 화면 상단 중앙에는 `TDC114PLUS` 타이틀을 명확하게 노출한다.
- 전화번호 입력은 `010-0000-0000` 형식의 입력/표현을 기본으로 한다.
- 안내 문구는 짧고 명확하게 유지한다.
- 로그인 화면 본문에서는 `입력하신 전화번호로 바론SSO 로그인 링크가 발송됩니다.` 문구를 중복 노출하지 않는다.
- 로그인 화면은 문자 승인 후 앱으로 돌아오면 자동 로그인된다는 흐름을 우선 안내한다.
- 전화번호 입력 중 키보드가 열려도 `로그인 링크 보내기` 버튼은 사용자가 바로 누를 수 있도록 화면에 계속 노출한다.
- 앱은 링크 발송 후 pending 상태를 유지하며 poll로 승인 완료를 확인한다.
- Baron SSO 공식 정책상 `/api/v1/auth/headless/link/init`은 승인 완료 후 redirect URL 필드를 지원하지 않는다.
- Baron SSO 공식 정책상 문자 링크를 클릭한 모바일 브라우저는 verify-only approver로 동작하며, 승인 완료 후 `sso.hmac.kr/ko/verify-cc` 또는 승인 완료 화면에 머무른다.
- 따라서 앱 화면은 `문자 링크 승인 후 TDC114PLUS 앱으로 돌아오면 자동 로그인됩니다` 흐름을 명확히 안내한다.
- 사용자가 앱으로 돌아오면 앱은 저장된 pendingRef로 poll을 재개하고, 승인 완료가 확인되면 직원검색 화면으로 자동 이동해야 한다.
- 사용자가 문자 링크를 클릭했을 때 앱이 바로 열리려면 Baron SSO 승인 완료 페이지가 앱 링크 또는 callback URL로 redirect하는 별도 정책/기능을 추가해야 한다.
- Baron SSO가 `sso.hmac.kr/ko/verify-cc` 승인 완료 화면에 머무르는 경우, 앱 단독 코드만으로 Chrome을 자동으로 앱으로 전환할 수 없음을 정책상 명확히 둔다.
- OIDC PKCE Hosted Login은 현재 기본 운영 UX가 아니며, 다시 적용하려면 별도 정책 변경 승인이 필요하다.
- 로그인 실패 메시지는 사용자 존재 여부를 과도하게 노출하지 않는다.
## 4. 직원검색 화면 정책
대상:
- `직원검색`
- 상단 조직 칩
- 직원/조직도/즐겨찾기 segment
### 4.1 초기 기본값
- 초기 조회 범위는 로그인 사용자의 `회사급 tenantSlug`
- 단, 초기 선택 상태는 가능하면 로그인 사용자의 `본인 팀 뱃지`가 실제 선택되도록 맞춘다.
- 초기 화면은 직원 목록 중심으로 시작
- 앱 최초 진입 시에는 조직 단계 표현에 필요한 상위 조직/내 팀 정보를 먼저 확보한다.
- 최초 직원 목록은 가족사 전체가 아니라 `내 팀` 또는 현재 초기 선택 scope에 필요한 인원만 조회한다.
- 초기 진입 또는 상단 뱃지 선택 시 직원 조회가 가족사 전체 조회로 새지 않도록 한다.
- 하위 조직을 표시해야 하는 단계에서는 기본 직원 목록 조회를 실행하지 않는다. 단, 선택 조직의 직접 소속 인원을 `직접 소속` 구역에 표시하기 위한 제한 조회는 허용한다.
- 자식 조직이 없는 leaf 조직 또는 검색 결과 표시가 필요한 상태에서만 직원 목록을 조회한다.
- 초기 진입 직후 phone/name 재조회로 개인 1명 또는 하위 팀 1개로 자동 축소하지 않음
- 초기 상단 칩에는 로그인 사용자의 `본인 팀 뱃지`를 함께 고정 노출한다.
- 본인 팀 뱃지는 세션의 `department`와 tenant 계층의 조직명을 우선 매칭해 결정한다.
- tenant 계층에 본인 팀 노드가 없더라도, 세션의 `department` 문자열로 본인 팀 뱃지를 synthetic chip 형태로 유지한다.
### 4.2 상단 칩 규칙
- 본인 소속 팀 칩이 있으면 상단 칩의 가장 앞에 노출한다.
- 항상 `전체` 칩이 존재한다.
- `전체` 칩은 본인팀 칩 다음 순서에 노출한다.
- 회사급 칩은 항상 노출한다.
- 본인 소속 팀 칩은 빠른 재선택용으로 유지한다.
- 다른 회사 칩을 선택한 뒤에도 본인 팀 칩은 사라지지 않는다.
- 현재 선택된 하위조직은 칩에서 사라지지 않아야 한다.
- 현재 선택된 칩은 비선택 칩보다 더 강한 배경색과 외곽 표현으로 즉시 구분 가능해야 한다.
- 상단 칩 수가 화면 폭을 넘으면 가로 스크롤로 계속 탐색 가능해야 한다.
- 상단 칩 overflow는 줄바꿈보다 `한 줄 가로 스크롤 + 스크롤 가능 인지성`을 우선한다.
### 4.3 `<전체>` 클릭 규칙
`<전체>`를 클릭하면 단계형 조직 탐색 모드로 전환한다.
진행 규칙:
1. `전체` 선택
- 가족사/회사 목록 표시
2. 회사 선택
- 해당 회사의 바로 아래 하위조직 목록 표시
3. 하위조직 선택
- 자식 조직이 있으면 그 다음 하위조직 목록 표시
- 자식 조직이 없으면 해당 조직 소속 직원 목록 표시
- leaf 조직이면 해당 조직 소속 직원 목록으로 진입
4. breadcrumb
- `전체 > 회사 > 하위조직` 경로를 클릭 가능하게 유지
즉, 상단 칩은 단순 필터가 아니라 조직 탐색 진입점 역할을 겸한다.
추가 표현 규칙:
- 상단 회사/고정팀 칩 영역과 하위 경로 칩 영역 사이에는 시각적 구분선을 둔다.
- breadcrumb 영역과 segment 사이의 구분 표식은 선 중심으로 단순하게 유지하고, 중앙 강조 아이콘은 기본 표현으로 두지 않는다.
- breadcrumb에서 현재 선택 중인 하위조직 칩은 비선택 칩보다 더 눈에 띄는 아이콘과 색으로 구분한다.
- leaf 조직 선택 후 직원 목록 진입은 조직명 문자열 비교보다 `선택 tenantSlug` 기준 조회를 우선한다.
- 하위조직 카드의 인원 수는 API 원문의 `memberCount`만 그대로 쓰지 않고, 해당 하위조직 자신과 모든 descendant 조직에 소속된 인원을 합산한 `subtree 인원 수`로 표시한다.
- 선택한 조직 아래에 자식 조직이 하나라도 있으면 하위조직 목록을 우선 표시한다.
- 선택한 조직에 직접 소속 인원이 있으면 하위조직 목록과 섞지 않고 별도 구역으로 표시한다.
- 직접 소속 별도 구역의 제목은 사용자가 현재 선택한 조직을 알 수 있도록 `{선택 조직명} 조직 관리자` 형식으로 표시한다.
- 직접 소속 별도 구역은 하위조직 목록보다 위에 표시한다.
- `디비전장`, `센터장` 등 상위 조직에 직접 매핑되고 하위 팀/부서 소속값이 비어 있는 인원은 `직접 소속` 구역에서 누락 없이 노출한다.
- 자식 조직이 없는 leaf 조직에 도달했을 때만 해당 조직에 직접 소속된 직원 목록을 표시한다.
- 하위조직 목록을 표시하는 동안에는 하위조직 전체 직원 목록을 섞어 표시하지 않는다. 단, 선택 조직에 직접 매핑된 인원 확인을 위한 제한 조회는 허용한다.
- leaf 조직의 직접 소속 인원이 0명이면 `검색 결과 없음` 표시가 가능하다. 단, 이 경우 실제 Baron SSO 조직 데이터상 해당 leaf에 members가 없는지 확인해야 한다.
- 다만 실제 API가 하위 tenant subtree를 제공하지 않는 구간에서는, 회사 하위 상세 트리는 완전 복원하지 못할 수 있으며 이 경우 본인 팀 synthetic chip과 회사 단위 직원 목록을 fallback으로 유지한다.
### 4.4 검색어 입력 규칙
- 검색어가 비어 있으면 조직 탐색 규칙을 우선 적용한다.
- 검색 범위는 항상 현재 선택된 상단 뱃지 scope를 기준으로 한다.
- 검색어가 있으면 현재 선택된 scope 안에서 직원 검색 결과를 우선 표시한다.
- 검색 중에는 drilldown 목록보다 검색 결과가 우선이다.
- 직원검색 입력창은 한글 IME 조합 입력을 막지 않는 일반 텍스트 입력으로 유지한다.
- 가족사 전인원 고정 검색은 기본 정책으로 사용하지 않는다.
- 한글 이름 검색은 한글 1자 이상 입력 시 조회 가능하다.
- 전화번호 검색은 숫자만 추출했을 때, 앞자리 `010`을 제외한 나머지가 2자리 이상일 때 조회 가능하다.
- 최소 입력 조건에 미달하면 조회 요청을 보내지 않고 현재 scope 기본 목록 또는 drilldown 상태를 유지한다.
- 최소 입력 조건 안내 문구를 사용자에게 짧게 노출할 수 있다.
### 4.5 즐겨찾기 규칙
- `즐겨찾기` 뷰는 조직 drilldown보다 즐겨찾기 결과 표시를 우선한다.
- 현재 선택된 scope 안에서 즐겨찾기를 필터링할 수 있다.
- 즐겨찾기 추가/해제는 직원 상세/목록 어느 쪽에서도 동일하게 동작해야 한다.
### 4.6 조직도 인원 정렬 규칙
- 조직도 그룹 내부 인원 정렬은 단순 API 수신 순서에 의존하지 않는다.
- 같은 조직/부서 내부에서는 `isManager == true` 인원을 최우선으로 노출한다.
- `isManager` 값이 없거나 false인 경우에 한해 `position``팀장`이 포함된 인원을 보조적으로 장으로 판단할 수 있다.
- 그 다음 순서는 직급/직위 우선순위를 반영한다.
- 기본 우선순위는 `사장 > 부사장 > 수석(연구원) > 전무 > 상무 > 이사 > 책임(연구원) > 부장 > 선임(연구원) > 과장 > 대리 > 사원`으로 본다.
- 위 기준이 같은 경우 마지막은 이름 가나다순으로 정렬한다.
- `미지정` 부서 그룹이 있더라도 동일한 인원 정렬 규칙을 적용한다.
### 4.7 직원 리스트 스크롤 규칙
- 검색창, 상단 뱃지, breadcrumb, segment 영역은 결과 인원 증가 때문에 함께 밀려 올라가면 안 된다.
- 하단 직원/조직 결과 영역만 독립적으로 세로 스크롤되어야 한다.
- 직원 리스트는 `Expanded` 영역 안의 `ListView` 계열로 구성한다.
- 인원이 많아도 상단 검색/뱃지/조직 탐색 영역은 같은 화면 안에서 유지되어야 한다.
## 5. 조직도 화면 정책
대상:
- `조직도` segment
규칙:
- 조직 drilldown 중 자식 조직이 있으면 하위조직 목록을 우선 보여준다.
- 하위조직 목록의 각 카드에는 해당 하위조직 subtree 기준 인원 수를 표시한다.
- leaf 조직에 도달하면 해당 leaf 조직에 직접 소속된 직원 목록/조직도 그룹 표시를 보여준다.
- 비-leaf 조직에 직접 소속된 인원이 있으면 하위조직 목록과 직원 목록을 한 화면에 섞지 않고 `직접 소속` 가상 그룹으로 구분해 표시한다.
- 조직도 화면은 직원검색 화면의 tenant navigation 상태를 공유한다.
- breadcrumb 영역과 직원/조직도/즐겨찾기 segment 사이에는 위아래 영역을 구분하는 시각 표식을 둔다.
## 6. 직원 상세 기능 정책
대상:
- bottom sheet 상세
- 전화/문자 버튼
규칙:
- 프로필 사진 URL이 있으면 직원 프로필 앞에 사진을 우선 노출한다.
- 프로필 사진 URL이 없으면 이니셜 또는 기본 아바타 fallback을 사용한다.
- 사번 기반 이미지 네이밍으로 사진을 연결하려면 직원 DTO에 사번 또는 사번으로 역매핑 가능한 안정 식별자가 있어야 한다.
- 이메일만으로 사번 이미지를 역매핑하는 방식은 별도 매핑 테이블이나 명명 규칙이 확정되기 전까지 기본 정책으로 채택하지 않는다.
- 전화번호가 없으면 전화/문자 버튼은 비활성화한다.
- 직원 상세는 tenantName, department, 직급/직위, 직무를 우선 노출한다.
- 즐겨찾기 토글은 상세에서도 가능해야 한다.
## 7. 구현 시 금지사항
- APK 직접 설치만으로 기능 검증이 끝났다고 판단하지 않는다.
- 조직 탐색 정책과 초기 조회 범위를 같은 규칙으로 섞지 않는다.
- 본인 확인 로직 때문에 초기 scope를 개인 1명으로 자동 축소하지 않는다.
- 하위조직 칩 또는 본인팀 칩이 선택 전후로 사라지도록 두지 않는다.
- 다른 회사를 눌렀다는 이유만으로 본인팀 뱃지를 제거하지 않는다.
- 회사/팀 뱃지 선택 시 가족사 전체 직원 목록을 먼저 가져온 뒤 앱에서만 필터링하는 방식을 기본 구현으로 사용하지 않는다.
- 하위조직 목록을 보여주는 단계에서 직원 목록 조회를 동시에 실행하지 않는다.
## 8. 변경 시 필수 검증
최소 검증:
```bash
./scripts/flutter-docker.sh test test/widget_test.dart test/directory/directory_filters_test.dart
```
변경 후 확인할 항목:
- 로그인 후 기본 범위가 회사급인지
- 로그인 후 본인팀 칩이 기본 노출되는지
- `<전체>` 클릭 시 가족사 목록으로 들어가는지
- 회사 선택 후 하위조직 목록이 나오는지
- leaf 조직 선택 후 직원 목록이 나오는지
- 하위조직 카드의 인원 수가 해당 하위조직 subtree 기준으로 합산되는지
- 자식 조직이 있는 비-leaf 조직에서는 직원 목록보다 하위조직 목록이 우선 표시되는지
- leaf 조직의 직접 소속 인원이 0명일 때만 `검색 결과 없음`이 표시되는지
- 본인팀 칩/선택 조직 칩이 사라지지 않는지
- 선택된 칩이 비선택 칩보다 명확히 구분되는지
- 현재 선택된 breadcrumb 칩이 아이콘과 색으로 명확히 구분되는지
- 칩이 많을 때 가로 스크롤로 끝까지 탐색 가능한지
- 이름 1자 검색과 전화번호 `010 제외 2자리` 검색 규칙이 맞는지
- 최소 입력 미달 시 조회를 남발하지 않고 현재 scope가 유지되는지
- 조직도 그룹 내에서 팀장/직급/이름 순 정렬이 맞는지
- 직원 목록이 많을 때 하단 결과 영역만 스크롤되는지
- 하위조직 목록 표시 중 하위조직 전체 직원 목록이 섞여 나오지 않는지
- 상위 조직 직접 소속 인원이 있으면 `직접 소속` 구역에 누락 없이 표시되는지
- 회사/팀 뱃지 선택 시 가족사 전체 직원 조회로 새지 않는지
- 정렬 기준이 `isManager -> 직급표 -> 이름` 순서와 일치하는지
@@ -0,0 +1,194 @@
# tdc114plus 테스트 정책
작성일: 2026-07-02
최종 개정일: 2026-07-09
상태: v2.1
목적: `tdc114plus` Flutter 앱의 mock 기반 개발, Swagger 계약 검증, 실제 API 연동 검증 기준을 단계별로 정의한다.
상위 기준 문서:
- `docs/00_policy_tdc114plus_decoupled_api_migration_2026-07-07.md`
- `docs/00_policy_tdc114plus_development_2026-07-02.md`
- `docs/00_contract_tdc114plus_api_2026-07-02.md`
## 1. 적용 범위
본 정책은 아래 영역에 적용한다.
- Flutter 앱 화면, 라우팅, 상태관리
- API client, repository, provider
- API 계약 기반 model, DTO, JSON 변환
- Baron SSO Hosted Login + PKCE 시작/callback/token 교환 흐름
- 직원검색, 조직도, 직원 상세
- 즐겨찾기 로컬 저장
- 전화걸기, 문자보내기 등 플랫폼 액션
## 2. 기본 검증 게이트
Flutter 앱 코드를 변경한 모든 작업은 아래 검증을 통과해야 한다.
```bash
./scripts/flutter-docker.sh analyze
./scripts/flutter-docker.sh test
```
필요 시 formatter를 적용한다.
```bash
./scripts/format-dart.sh
```
검증 실패 상태의 코드는 `main` 브랜치에 반영하지 않는다.
## 3. 테스트 레이어
### 3.1 Mock 레이어
목적:
- 백엔드 미구현 상태에서도 화면과 상태 흐름을 개발 가능하게 유지한다.
대상:
- widget test
- fake/mock repository
- local mock data
핵심 검증:
- 로그인 화면 표시
- Baron SSO 로그인 시작 버튼 렌더링
- 앱 내부에서 휴대폰번호를 직접 입력받지 않는지 확인
- callback token 교환 완료 후 첫 화면 진입
- 직원검색, 조직 탐색, 즐겨찾기, 상세 화면 흐름
### 3.2 계약 레이어
목적:
- Swagger 기준 request/response DTO와 앱 모델 간 불일치를 조기에 찾는다.
대상:
- `fromJson`, `toJson`
- API error parsing
- request body 생성
- polling 상태값 파싱
핵심 검증:
- OIDC authorization URL query 생성
- PKCE `state`, `nonce`, `code_verifier` 저장/검증
- token endpoint 응답 DTO
- 세션 저장 모델
- directory/organization DTO
- 400/401/403/429/timeout 처리
### 3.3 실제 API 레이어
목적:
- mock 구조가 실제 API와 동일한 사용자 흐름을 유지하는지 확인한다.
대상:
- smoke test
- integration test
- staging 또는 동등 환경 점검
핵심 검증:
- Hosted Login authorization endpoint 진입
- App Link callback 수신
- PKCE token 교환 후 세션 저장
- 로그인 후 직원검색 첫 화면 진입
- directory/organization 실제 응답 정합성
## 4. 우선 테스트 대상
강한 단위 테스트가 필요한 영역:
- API model `fromJson`, `toJson`
- API error parsing
- authorization URL 생성 규칙
- 로그인 상태 전이
- `state`, `nonce`, `code_verifier` 처리
- 세션 저장/삭제/복원
- 직원검색 query/filter 생성
- 가족사/조직 필터 상태
- 즐겨찾기 추가/삭제/조회
- API 실패, 401, 403, 429, timeout 처리
화면 테스트가 필요한 영역:
- 앱 실행 후 로그인 화면 표시
- 앱 내부 전화번호 입력창 미노출
- callback 처리 완료 후 직원검색 진입
- 직원 검색어 입력과 결과 표시
- 가족사 필터 선택/해제
- 조직도 탐색
- 직원 상세 표시
- 전화걸기/문자보내기 액션 노출
- 즐겨찾기 토글
## 5. 단계별 테스트 전략
### 5.1 Phase A: Mock 우선 개발
목표:
- API 없이도 앱의 핵심 화면 흐름을 검증한다.
완료 기준:
- `flutter analyze` 통과
- `flutter test` 통과
- mock repository 기반 주요 widget test 존재
### 5.2 Phase B: 계약 정합성 검증
목표:
- Swagger 기준 DTO와 앱 모델의 구조를 맞춘다.
완료 기준:
- DTO 파싱/직렬화 테스트 통과
- 에러 응답 테스트 통과
- 로그인 흐름 상태값 테스트 통과
### 5.3 Phase C: 실제 API 연동
목표:
- Hosted Login + PKCE 흐름이 실제 Baron SSO RP 설정과 맞물리는지 확인한다.
실행 원칙:
- 실제 API smoke는 환경 준비 확인 후 실행한다.
- local, staging, 기타 검증 환경 중 어떤 환경을 쓰더라도 동일한 계약 기준을 적용한다.
- 실제 검증은 `authorization URL 열기 -> Baron SSO Hosted Login에서 인증 -> App Link callback -> state 검증 -> token 교환 -> 세션 저장 -> 첫 화면 진입` 흐름 완료를 기준으로 본다.
- `phone-login`, `link/poll`은 개발용 fallback 또는 Baron SSO 내부 구현 참고일 뿐, 신규 앱 기본 로그인 검증 완료로 간주하지 않는다.
완료 기준:
- authorization URL query 확인
- callback 수신과 state 검증 확인
- token 교환 후 세션 저장 확인
- 로그인 후 첫 화면 진입 확인
- directory/organization 응답 필드 정합성 확인
## 6. 수동 검증 원칙
- Android target 검증은 mock 검증과 별도로 본다.
- 로그인 완료 가정 세션 주입 경로는 post-login 기능 점검용으로만 사용한다.
- 실제 승인 완료 검증을 세션 bootstrap 경로로 대체하지 않는다.
- staging 반영 검토 전에는 승인 완료 end-to-end를 최소 1회 이상 확인하는 것을 목표로 한다.
## 7. 문서 및 로그 반영 원칙
- 테스트 기준이 바뀌면 정책 문서를 먼저 갱신한다.
- 중요한 실행 결과는 `docs/test-logs/`에 기록한다.
- 실패 시에는 원인, 재현 조건, 후속 조치를 함께 남긴다.
- mock 기준 통과와 real API 기준 통과를 구분해서 기록한다.
@@ -0,0 +1,91 @@
# 공기계 USB 테스트 시작 체크리스트
작성일: 2026-07-09
상태: v1.0
목적: `tdc114plus` 신규앱을 공기계 USB 연결 기준으로 빠르게 기동하고 테스트를 시작하기 위한 최소 절차를 고정한다.
## 1. 전제
- 오늘 기본 테스트 대상은 `공기계 1대`다.
- Android Studio는 필수가 아니다.
- 기본 연결 방식은 `Windows ADB server 공유 + WSL/Docker Flutter`다.
## 2. 시작 순서
1. Windows에서 `일반 PowerShell`을 연다.
2. WSL에서 `VS Code``tdc114plus` 워크스페이스를 연다.
3. 공기계를 USB로 연결한다.
4. 공기계에서 `USB 디버깅 허용` 팝업이 뜨면 허용한다.
5. 공기계 USB 용도는 `파일 전송`으로 둔다.
## 3. Windows 확인
일반 PowerShell에서 실행:
```powershell
& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" devices
```
기대 결과:
- 공기계 1대가 `device`
문제 시:
- `unauthorized`: 폰 화면에서 허용
- `offline`: 케이블 재연결, USB 모드 재확인, 다시 `adb devices`
## 4. WSL startup
WSL 터미널에서 실행:
```bash
cd /home/ubuntu/workspace/tdc114plus
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/startup.sh --dry-run --wait=40
```
dry-run 이상 없으면 실제 실행:
```bash
cd /home/ubuntu/workspace/tdc114plus
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/startup.sh --auto --wait=40
```
## 5. startup 통과 기준
- Android target preflight 통과
- Baron runtime 기동 완료
- `check-baron-api-env.sh` 통과
- `api-smoke.sh` 통과
## 6. 공기계 로컬 개발모드 준비
Windows 일반 PowerShell에서 실행:
```powershell
& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" -s <DEVICE_ID> reverse tcp:5000 tcp:5000
```
`<DEVICE_ID>``adb devices`에 표시된 공기계 ID를 사용한다.
## 7. 앱 실행
WSL 터미널에서 실행:
```bash
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 \
TDC114_FLUTTER_DEVICE_ID=<DEVICE_ID> \
TDC114_SMOKE_ENV_FILE=scripts/.env.android-device.local \
./scripts/manual-postlogin-run.sh
```
## 8. 오늘 테스트 시작점
앱이 공기계에 뜨면 아래부터 점검을 시작한다.
- 로그인 흐름
- 직원검색
- 조직도
- 즐겨찾기
- 전화/문자 버튼 노출
@@ -0,0 +1,167 @@
# 퇴근 전 기동 종료 및 정리 체크리스트
**작성일**: 2026-07-06
**최종 업데이트**: 2026-07-08
## 목적
전날 퇴근하면서 VS Code, 터미널, Android target, Docker 컨테이너 등 개발에 사용한 런타임을
안정적으로 종료하여 다음날 아침에 원활히 재기동할 수 있도록 준비한다.
**핵심 정책**: 소스코드 변경을 요하지 않는 종료/정리 작업(컨테이너 중지, 권한 수정, 로그 수집, 임시파일 정리 등)은 사용자 승인 없이 자동으로 실행한다.
---
## 적용 대상
- 작업 디렉터리: `/home/ubuntu/workspace/tdc114plus`
- Baron SSO API worktree: `/home/ubuntu/workspace/baron-sso-tdc114plus-api`
- Android target (실기기 우선, emulator fallback) + WSL/Docker 환경
- 로컬 Docker 엔진
---
## 간단 체크리스트 (퇴근 5분 요약)
1. 모든 작업 저장 및 커밋(필요시).
2. 통합 테스트/빌드가 실행 중이면 중지.
3. Android target 정리 및 필요 시 ADB 연결 해제.
4. Docker 앱/컨테이너 정상 종료(아래 상세 절차).
5. 로그/임시파일 수집 및 정리.
6. VS Code 종료.
---
## 상세 종료 절차 (권장 순서)
1) 열린 작업 저장
- 변경 중인 파일을 저장하고 로컬 커밋을 권장. 소스 변경은 수동 승인 항목이므로 자동 처리하지 않는다.
2) 실행 중인 테스트/빌드 종료
```bash
# 통합/로컬 테스트나 빌드 프로세스가 있으면 종료
# (예: integration_tests.sh 백그라운드 프로세스 종료)
pkill -f /home/ubuntu/workspace/tdc114plus/scripts/integration_tests.sh || true
pkill -f /home/ubuntu/workspace/tdc114plus/scripts/flutter-docker.sh || true
```
3) Android target 정리
- 실기기 우선 운영이면 USB 연결만 정리하고, emulator fallback을 썼다면 Windows에서 에뮬레이터를 끈다.
- 당일 `TDC114_ADB_CONNECT_ADDRESS`를 알고 있을 때만 WSL에서 연결을 끊는다.
```bash
# WSL에서 ADB 연결 해제: 당일 실제 주소로만 실행
TDC114_ADB_CONNECT_ADDRESS=172.21.128.1:<EMULATOR_PORT> \
adb disconnect 172.21.128.1:<EMULATOR_PORT> 2>/dev/null || true
```
- 주소를 모르면 ADB disconnect는 생략한다. 과거 포트(`5555`, `5559` 등)를 임의로 disconnect하지 않는다.
- 필요시 포트포워드/portproxy 정리(Windows 측에서 수행 필요)
4) 로그 수집
- compose 로그는 `down` 전에 수집한다. 종료 후에는 컨테이너 로그가 사라질 수 있다.
```bash
# 로그 저장(날짜별)
mkdir -p /home/ubuntu/workspace/tdc114plus/logs/$(date +%F)
cd /home/ubuntu/workspace/baron-sso-tdc114plus-api
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml logs --no-color > /home/ubuntu/workspace/tdc114plus/logs/$(date +%F)/baron-compose.log 2>&1 || true
```
5) Docker 컨테이너 및 스택 안전하게 중지
- Baron SSO 및 app 관련 스택을 정상적으로 내린다 (데이터베이스 유지 여부는 상황에 따름).
```bash
# 권장: 모든 관련 compose 파일을 포함해 정상 중지 (데이터 유지)
cd /home/ubuntu/workspace/baron-sso-tdc114plus-api
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml down
# tdc114plus 앱 스택이 별도라면 종료
cd /home/ubuntu/workspace/tdc114plus
# (예: 앱 관련 compose가 있다면) docker compose down
```
- 모든 관련 컨테이너 완전 제거(옵션, 재기동 시 네임 충돌 방지)
```bash
# 선택적(정리 필요 시 실행)
docker ps -a --format '{{.Names}}' | grep -iE "baron|ory|tdc114" | xargs -r docker rm -f || true
```
6) 임시파일 정리
```bash
# 임시 파일 정리 (docker cache 등)
# (유지해야 하는 캐시는 삭제하지 않도록 주의)
```
7) 권한/생성된 파일 기본 정리 (자동)
```bash
# config 디렉터리 권한이 root로 생긴 경우 사용자가 쓸 수 있게 복구
cd /home/ubuntu/workspace/baron-sso-tdc114plus-api
chmod -R u+w config/.generated/ 2>/dev/null || true
chown -R $(id -u):$(id -g) config/.generated/ 2>/dev/null || true
```
8) VS Code 및 터미널 종료
- VS Code: 모든 변경 저장 후 종료.
- 터미널 세션: 열린 터미널을 종료.
---
## 자동화 규칙 요약
- 자동 실행(승인 불필요): 대상 helper 프로세스 종료, 로그 수집, 컨테이너 중지, 임시파일 정리, 권한 복구, ADB 연결 해제
- 수동 승인 필요: 소스코드 커밋/푸시, 설정 파일 수정, 환경 변수 변경, DB 마이그레이션
---
## 스크립트 사용법: `scripts/shutdown.sh`
이 문서의 절차는 자동화 스크립트 `scripts/shutdown.sh`로 실행할 수 있습니다. 스크립트는 기본적으로 **안전 모드(dry-run)**로 동작하며, 실제 종료 작업과 선택적 파괴적 정리(컨테이너 강제 제거 등)는 `--auto` 옵션을 사용해야 수행됩니다.
간단한 사용 예시:
```bash
# 문법 검사
bash -n scripts/shutdown.sh
# Dry-run (권장): 실제로 파괴적 명령을 실행하지 않고 어떤 작업을 수행할지 확인합니다.
./scripts/shutdown.sh --dry-run
# 실제 실행 (주의): --auto 플래그는 컨테이너 강제 제거 같은 파괴적 정리를 허용합니다.
./scripts/shutdown.sh --auto
```
스크립트는 실행 로그를 `/home/ubuntu/workspace/tdc114plus/logs/<YYYY-MM-DD>/shutdown.log`에 기록합니다. 자동화된 종료를 CI나 cron에 등록할 경우 `--auto`를 사용하되, 로그 보관 정책과 백업을 확인하십시오.
## 체크아웃/확인 항목 (퇴근 직전)
- [ ] 작업 내용 저장/커밋(또는 스태시)
- [ ] 대상 helper 프로세스 종료 확인
- [ ] Android target 정리 및 필요 시 ADB 연결 해제
- [ ] `docker compose down` 실행 완료
- [ ] 주요 로그가 `/home/ubuntu/workspace/tdc114plus/logs/$(date +%F)`에 보관되었는지 확인
- [ ] `config/.generated` 쓰기 권한이 정상인지 확인
- [ ] VS Code 종료
---
## 복구 지침 요약 (다음날 재기동 관련)
- 다음날 아침에는 `docs/checklist_morning_startup_runtime_2026-07-03.md`를 따라 재기동한다.
- 자동으로 중지된 항목(컨테이너 등)은 사용자의 승인 없이 재기동 스크립트가 처리한다.
---
## 변경 이력
- v1.0 (2026-07-06): 초기 작성
@@ -0,0 +1,474 @@
# 출근 후 기동 확인 및 복구 체크리스트
**작성일**: 2026-07-03
**최종 업데이트**: 2026-07-19
**버전**: 2.5 (개발용 로컬 Baron worktree와 최종 배포 목표 구조 구분 반영)
## 목적
전날 퇴근하면서 VS Code, 터미널, Android target을 모두 종료한 뒤, 다음 출근 시 `tdc114plus` 작업을 빠르게 재개할 수 있도록 기동 확인과 복구 절차를 고정한다.
**핵심 정책**: 소스코드 변경을 요하지 않는 자동 복구(컨테이너 재시작, 충돌 컨테이너 정리, 권한 수정, 디렉터리 정리 등)는 사용자 승인 없이 자동으로 진행한다.
---
## 1. 적용 대상
- 앱 저장소: `/home/ubuntu/workspace/tdc114plus`
- Baron SSO API worktree: `/home/ubuntu/workspace/baron-sso-tdc114plus-api`
- auth 중계서버 저장소: `/home/ubuntu/workspace/tdc114plus-auth`
- Windows ADB 서버 공유 기반 Android target + WSL/Docker Flutter 조합
주의:
- 이 체크리스트는 `현재 개발/검증용 업무시작 기동` 기준이다.
- 최종 배포 목표 구조에서는 로컬 `baron-sso-tdc114plus-api` 기동이 없어지는 방향을 목표로 한다.
- 다만 2026-07-19 현재는 아직 일부 로그인/조직 연동 검증이 로컬 Baron worktree에 의존하므로, 업무 시작 기동 대상에서 바로 제거하지 않는다.
---
## 2. 출근 후 기본 순서
아래 순서를 기본값으로 사용한다.
1. VS Code를 연다.
2. 작업 기준 문서를 먼저 연다.
- `docs/00_guide_tdc114plus_work_progress_timetable_2026-07-02.md`
- `docs/daily-issues/YYYY-MM-DD.md`
3. Android target 선행 확인
4. Baron SSO runtime 초기화 (아래 섹션 참고)
5. `tdc114plus-auth` broker 기동 및 health 확인
6. 1차 상태 확인 명령 실행
7. Integration test 실행
현재 해석:
- `tdc114plus``tdc114plus-auth`는 최종 배포 직접 대상이다.
- `baron-sso-tdc114plus-api`는 현재 개발 중 연동 검증용 보조 worktree다.
- Baron SSO 원본 `staging`/`production` 연동으로 완전히 전환되기 전까지는 이 보조 worktree 기동 여부가 앱 개발 중 동작에 직접 영향을 줄 수 있다.
---
## 3. 런타임 초기화 및 1차 확인
### 3.0 Android target 선행 확인
`startup.sh`는 이제 **가장 먼저** Android target 상태를 확인한다. 실기기 또는 emulator가 `offline`, `unauthorized`, `connection refused` 중 하나면 Baron runtime 기동 전에 멈추고, Windows에서 무엇을 해야 하는지 단계별로 출력한다.
기본 정책:
- 기본 타깃은 실기기 1대다. emulator는 fallback일 때만 1대만 켠다.
- Windows `adb.exe devices`에서 대상 Android target이 `device` 상태인지 먼저 확인한다.
- 표준 연결 방식은 Windows ADB server 공유 방식인 `ADB_SERVER_SOCKET=tcp:172.21.128.1:5037`을 사용한다.
- 직접 emulator port 연결인 `TDC114_ADB_CONNECT_ADDRESS=<host>:<port>`는 Docker local ADB가 꼭 필요한 예외 상황에만 사용한다.
- Windows 단독 `device` 상태가 확인되기 전에는 WSL/Docker Android 스크립트를 실행하지 않는다.
권장 실행:
```bash
# Android preflight + startup 계획 확인
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/startup.sh --dry-run --wait=40
# 실제 기동
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/startup.sh --auto --wait=40
```
Android target preflight 실패 시 사용자가 로컬 PC에서 최소한으로 해야 하는 일:
1. Windows에서 Android Studio를 연다.
2. 실기기면 USB 디버깅 연결을 확인하고, emulator fallback이면 Device Manager에서 1대만 켠다.
3. Windows PowerShell에서 `adb.exe devices`를 실행해 `device` 상태를 확인한다.
4. 여전히 `offline`이면 대상 기기 또는 emulator를 재시작한다.
5. Windows adb는 정상인데 WSL/Docker에서만 실패하면 우선 `5037 -> 127.0.0.1:5037` portproxy와 방화벽 rule을 확인한다.
6. `5037` 공유 방식은 정상인데 특정 integration test에서만 막히면 그때 `<EMULATOR_PORT> -> 127.0.0.1:<EMULATOR_PORT>` 직접 연결 보조 경로를 검토한다.
이 단계는 Codex가 대신 할 수 없다.
Codex가 계속할 수 있는 작업:
- WSL/Docker에서 Android target 재인식 확인
- Baron runtime 기동
- API smoke 확인
- integration/manual Android 명령 실행
- `tdc114plus-auth` broker 기동 확인 및 자동 실행 시도
### 3.05 앱 직접 App Link callback 제거
2026-07-15 기준 신규 앱은 Baron SSO OIDC callback을 직접 받지 않는다.
기본 정책:
- 앱은 `https://114.hmac.kr/auth/callback` App Link를 사용하지 않는다.
- 앱은 `tdc114plus-auth``/api/v1/auth/link/init`, `/api/v1/auth/link/poll`만 호출한다.
- Baron SSO OIDC callback은 `tdc114plus-auth`가 받는다.
- 필요한 redirect URI는 `https://114-auth.hmac.kr/api/v1/auth/oidc/callback`이다.
- Windows 로컬 App Link 테스트 서버는 업무 시작 기동 대상이 아니다.
참조 문서:
```text
docs/00_guide_tdc114plus_auth_broker_redirect_flow_2026-07-15.md
```
### 3.1 Baron SSO 런타임 전체 초기화
**중요**: Baron SSO는 인프라(PostgreSQL, Redis, ClickHouse)와 Ory 인증(Kratos, Hydra, Keto) 서비스가 **모두 필수**다.
단순히 `docker compose up -d`로는 불충분하다. **반드시 모든 compose 파일을 포함**해야 한다:
```bash
cd /home/ubuntu/workspace/baron-sso-tdc114plus-api
# 모든 compose 파일 포함하여 안전 중지 후 재시작
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml down 2>/dev/null || true
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml up -d
# compose up 실패 시(예: name conflict) 기존 관련 컨테이너 정리 후 1회 재시도
docker ps -a --format '{{.Names}}' | grep -iE "baron|ory" | xargs -r docker rm -f
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml up -d
# 서비스 안정화 대기 (인지 마이그레이션/헬스체크 완료)
sleep 30
# tdc114plus로 돌아가기
cd /home/ubuntu/workspace/tdc114plus
```
### 3.2 1차 상태 확인 명령
```bash
cd /home/ubuntu/workspace/tdc114plus
# Baron runtime 상태 확인 (스크립트로 자동 실행 가능)
./scripts/check-baron-api-env.sh
# API smoke 테스트 (스크립트로 자동 실행 가능)
./scripts/api-smoke.sh
```
**실행 방법 (권장)**:
- 수동: 위 명령을 복사해 실행
- 자동(권장): `scripts/startup.sh`를 사용
**자동 실행 예 (dry-run 권장)**:
```bash
# Dry-run: Android preflight 포함, 어떤 작업을 할지 미리 확인
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/startup.sh --dry-run
# 실제 재기동(주의)
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/startup.sh --auto --wait=40
```
**기대 결과**:
- `check-baron-api-env.sh`: `"RESULT ready-ish: 0 failure(s), 0 warning(s)"`
- `api-smoke.sh`: 성공 코드 0
- `tdc114plus-auth` health: `{"jwks":"ok","provider":"baron","status":"ok"}`
- `scripts/startup.sh --auto`: Android preflight, Baron runtime, `tdc114plus-auth`, smoke 조건 중 하나라도 충족하지 못하면 비정상 종료(exit 1)
---
## 4. 자동 복구 정책 및 규칙
### 4.1 자동 복구 대상 (사용자 승인 불필요)
다음 사항들은 소스 변경을 요하지 않으므로 **자동으로 복구**한다:
- **컨테이너 미실행**: 자동 재시작 또는 전체 재기동
- **컨테이너 명 충돌**: 기존 컨테이너 강제 제거 후 1회 재시작
- **권한 문제**: `chmod`, `chown` 자동 수정
- **설정 디렉터리 부재**: 자동 생성 또는 복원
- **헬스체크 실패 또는 warning**: 최대 6회 재시도
- **서비스 안정화 대기**: 자동 진행 (최대 60초)
### 4.2 수동 개입 필요 (사용자 승인 필요)
다음 사항들은 설정 또는 소스 변경을 요하므로 **명시적 승인**을 받는다:
- 소스코드 수정
- 환경 변수 값 변경
- 설정 파일 내용 수정
- 데이터베이스 마이그레이션 또는 초기화
- API 엔드포인트 주소 변경
---
## 5. 실패 시 복구 순서
### 5.1 `check-baron-api-env.sh` 실패 또는 warning 다수
Baron SSO runtime 상태 확인 및 복구:
```bash
cd /home/ubuntu/workspace/baron-sso-tdc114plus-api
```
**자동 복구 절차** (순차 실행):
```bash
# 1단계: 안전 중지 후 전체 재시작
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml down 2>/dev/null || true
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml up -d
# 2단계: name conflict가 보이면 관련 컨테이너 정리 후 1회 재시작
docker ps -a --format '{{.Names}}' | grep -iE "baron|ory" | xargs -r docker rm -f
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml up -d
# 3단계: 서비스 안정화 대기
sleep 30
# 4단계: 재확인
cd /home/ubuntu/workspace/tdc114plus
./scripts/check-baron-api-env.sh
```
**상태 확인 명령어**:
```bash
docker ps --format '{{.Names}} {{.Status}}' | grep -E "baron_backend|baron_gateway|ory_kratos|ory_postgres"
```
**정상 기대 상태**:
| 서비스 | 상태 | 설명 |
|--------|------|------|
| `baron_backend` | Up (healthy) | 메인 백엔드 서비스 |
| `baron_gateway` | Up | 리버스 프록시 |
| `baron_postgres` | Up (healthy) | 메인 데이터베이스 |
| `baron_redis` | Up | 캐시/세션 저장소 |
| `ory_postgres` | Up (healthy) | Ory 데이터베이스 |
| `ory_kratos` | Up | 사용자 관리/인증 |
| `ory_hydra` | Up | OAuth2/OIDC 제공자 |
| `ory_keto` | Up | 권한 관리 |
---
### 5.2 `api-smoke.sh` 실패 (HTTP 502/503)
#### 원인 분석
```bash
# Ory Kratos 로그 확인 (인증 서비스)
docker logs ory_kratos 2>&1 | tail -30
# Baron backend 로그 확인
docker logs baron_backend 2>&1 | tail -30
# 전체 컨테이너 상태
docker ps | grep -iE "baron|ory"
```
#### 자동 복구
Ory 또는 Baron 서비스가 준비 완료되지 않은 경우:
```bash
cd /home/ubuntu/workspace/baron-sso-tdc114plus-api
# 전체 재시작 (가장 안전한 방법)
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml down
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml up -d
sleep 40
# 재확인
cd /home/ubuntu/workspace/tdc114plus
./scripts/api-smoke.sh
```
#### HTTP 401 (인증 오류)
```bash
cat /home/ubuntu/workspace/tdc114plus/scripts/.env.smoke.local | grep -E "PHONE|PROVIDER"
```
전화번호, provider 설정이 올바른지 확인. 변경 필요시 사용자 승인 후 수정.
---
## 6. Android target 확인
`startup.sh`가 이 단계를 먼저 수행하지만, 수동 재확인이 필요하면 아래 명령을 쓴다.
실기기 우선이면 USB 디버깅 연결을 먼저 확인하고, emulator fallback이면 Windows에서 Android Studio emulator를 켠다.
그 후 WSL/Docker 기준 확인:
```bash
cd /home/ubuntu/workspace/tdc114plus
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/flutter-docker.sh devices
```
**완료 기준**:
- `emulator-<PORT>` 또는 Android target이 표시된다.
- 직접 emulator port 연결은 표준 방식이 실패하거나 integration test 보조 경로가 필요할 때만 사용한다.
### 6.1 Android device가 보이지 않을 때
우선 정책 문서를 참고한다:
- `docs/policy_android_studio_wsl_adb_2026-07-03.md`
- `docs/scenario_android_emulator_device_integration_test_2026-07-03.md`
가장 흔한 확인 포인트:
- Windows emulator가 실제로 켜져 있는가?
- Windows `adb.exe devices`에서 대상이 `device` 상태인가?
- 당일 emulator port에 맞는 portproxy가 살아 있는가?
- Docker local ADB가 `device` 상태를 보는가?
---
## 7. Android SDK license 관련
현재 `scripts/flutter-docker.sh`에는 Android SDK license 선행 승인 로직이 들어 있다.
다음과 같은 오류가 나면 먼저 cache 상태를 의심한다:
- `ndk;28.2.13676358` license not accepted
- `CMake 3.22.1` license not accepted
**확인 경로**:
```bash
ls -la /home/ubuntu/workspace/tdc114plus/.docker-cache/flutter/android-sdk/licenses/
```
이 디렉터리들이 비어 있지 않으면 재사용되는 것이 정상이다.
---
## 8. 정상 기대 상태
정상 재개 기준:
-`./scripts/check-baron-api-env.sh` 통과 (0 failures, 0 warnings)
-`./scripts/api-smoke.sh` 통과 (성공 코드 0)
- ✅ Android emulator device 인식 성공
- ✅ integration test 통과
**Integration test에서 확인할 실제 홈화면 기대값**:
로그인 후 홈화면에는 `문형석`의 조직 slug `is-3` 기준 팀 직원목록이 보여야 한다:
- `직원검색`
- `검색 결과`
- `문형석`
- `IS3`
---
## 9. 다음 조치
위 순서 중 어느 단계에서 실패했는지 `docs/daily-issues/YYYY-MM-DD.md`에 남긴다.
기록할 최소 항목:
- 실패 단계 (예: 3.2, 5.1, 5.2 등)
- 첫 번째 에러 메시지
- 수행한 자동 복구 명령
- 복구 성공 여부
- 추가 수동 개입 필요 여부 및 사유
**기록 예시**:
```
## 2026-07-06 출근 재기동
### 진행 상황
- [x] 3.1 Baron SSO 초기화: 성공
- [x] 3.2 상태 확인: 초기 실패 (baron_backend not running)
- [x] 5.1 자동 복구: docker compose 재시작 → 성공
- [x] 5.2 api-smoke 재확인: 통과
- [ ] 6.0 Android emulator: 미진행 (시간 부족)
### 실패 내역
- Initial check-baron-api-env.sh: WARN baron_backend not running
- Resolved with: docker compose -f ... up -d
- No source code changes needed
```
---
## 부록: 명령 치트시트
### 빠른 전체 초기화 (추천)
```bash
# 1. Baron SSO 전체 재시작 (권장)
cd /home/ubuntu/workspace/baron-sso-tdc114plus-api
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml down 2>/dev/null || true
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml up -d
sleep 30
# 2. tdc114plus 상태 확인
cd /home/ubuntu/workspace/tdc114plus
./scripts/check-baron-api-env.sh && ./scripts/api-smoke.sh
```
### 컨테이너 상태 모니터링
```bash
# 실시간 로그 보기
docker logs -f baron_backend # 백엔드 로그
docker logs -f ory_kratos # 인증 로그
# 모든 baron/ory 컨테이너 상태
docker ps | grep -iE "baron|ory"
# 전체 상태 요약
docker ps --format '{{.Names}} {{.Status}}'
```
### 긴급 초기화 (최후의 수단)
```bash
cd /home/ubuntu/workspace/baron-sso-tdc114plus-api
# 모든 관련 컨테이너 강제 제거
docker ps -a --format '{{.Names}}' | grep -iE "baron|ory|sso" | xargs -r docker rm -f
# 전체 재시작
docker compose -f docker-compose.yaml -f compose.infra.yaml -f compose.ory.yaml up -d
# 서비스 안정화 대기
sleep 40
# 상태 확인
docker ps | grep -iE "baron_backend|ory_kratos"
```
### 일반적인 문제 해결
```bash
# 권한 문제 수정 (config 디렉터리 쓰기 불가)
cd /home/ubuntu/workspace/baron-sso-tdc114plus-api
chmod -R u+w config/.generated/ 2>/dev/null || true
# Ory 마이그레이션 재시도
docker compose -f docker-compose.yaml -f compose.ory.yaml up kratos-migrate
# 특정 컨테이너만 재시작
docker restart baron_backend
docker restart ory_kratos
# 로그 대량 확인
docker logs baron_backend 2>&1 | grep -i error | tail -20
```
---
## 변경 이력
### v2.0 (2026-07-06)
- 자동 복구 정책 섹션 추가 (섹션 4)
- Ory 포함 필수 명시 (섹션 3.1)
- 컨테이너 정리 자동화 (섹션 5.1)
- 안정화 대기 시간 추가 (30~40초)
- 권한 문제 자동 처리 설명 추가
- 명령 치트시트 부록 추가
- 문제 기록 템플릿 추가 (섹션 9)
### v1.0 (2026-07-03)
초기 버전: 기본 기동 절차 및 복구 순서
@@ -0,0 +1,161 @@
# pre-staging 수동 점검 체크리스트
작성일: 2026-07-06
상태: v0.2 현재 상태 반영
목적: `tdc114plus` 신규 전화번호 승인 로그인 기능을 staging 반영 전에 로컬 runtime, Android target, 문서 기준으로 점검할 항목을 고정한다.
## 1. 사용 시점
아래 상황에서 이 문서를 사용한다.
- 팀장에게 staging 반영 검토를 요청하기 전
- Android target 기준 수동 점검을 다시 수행할 때
- local runtime과 staging runtime의 차이를 설명해야 할 때
## 2. 진행 순서
아래 순서로 점검한다.
1. 로컬 구현 고정 상태 확인
2. 자동화 테스트 재확인
3. 로컬 API 계약 점검
4. Android target UI 점검
5. Android target 실사용 흐름 점검
6. 예외 시나리오 점검
7. staging 반영 직전 확인
## 3. 점검 항목
### 3.1 로컬 구현 고정 상태
- [x] 신규 로그인 흐름이 기존 원본 소스에 직접 덮어쓰지 않고 신규 API 경로 중심으로 분리되었는가
- [x] 기존 직원검색, 조직도, 직원 상세, 즐겨찾기 흐름이 회귀하지 않았는가
- [x] 변경 파일 목록과 영향 범위를 설명할 수 있는가
현재 판단:
- 앱과 backend 모두 기존 `phone-login`을 직접 덮어쓰지 않고 headless 승인 계약 기준으로 재정렬하는 방식으로 진행했다.
- 회귀 여부는 Flutter test, backend test, Android target 기준 직원검색 진입 확인으로 1차 검증했다.
### 3.2 자동화 테스트 재확인
- [x] Flutter unit test 통과
- [x] Flutter widget test 통과
- [x] backend handler/server test 통과
- [x] `scripts/api-smoke.sh` 기본 검증 통과
- [ ] Android integration test 재실행 결과 기록 완료
현재 판단:
- 단위/위젯/backend/smoke 기준의 기본 자동화는 확보되었다.
- Android integration test 재실행은 시도했고, 결과도 로그에 남겼다.
- 다만 2026-07-06 재시도는 `172.21.128.1:5555 offline`으로 Android target 연결이 실패해 기능점검 통과로는 반영하지 못했다.
### 3.3 로컬 API 계약 점검
- [x] headless 로그인 시작/승인조회 계약 존재 확인
- [x] 레거시 경로와 신규 계약의 차이 설명 가능
- [x] invalid 요청 기준 validation 응답 확인
- [x] 임의 `pendingRef` 기준 expired 또는 pending 응답 확인
- [x] local runtime에서 실제 승인 완료 E2E가 막히는 원인을 설명할 수 있는가
설명 기준:
- local Baron SSO runtime 안에 테스트 번호의 identity/user mirror가 없으면, 번호가 운영 또는 다른 환경에 등록되어 있어도 local 승인 완료 검증은 끝까지 진행되지 않는다.
### 3.4 Android target UI 점검
- [x] 로그인 화면이 정상 표시되는가
- [x] 빈 전화번호 validation이 동작하는가
- [x] `Baron SSO로 로그인` 버튼이 Hosted Login 화면을 여는가
- [ ] 인증 대기 중 문구가 자연스럽게 표시되는가
- [ ] 로딩 상태가 과도하게 길거나 멈춘 것처럼 보이지 않는가
- [ ] 오류 메시지가 내부 시스템 상세를 노출하지 않는가
현재 판단:
- 로그인 화면, 입력, 버튼 동작 자체는 확인했다.
- 승인 대기 중 문구와 장시간 polling 체감, generic 오류 문구는 현재 단계에서 다시 한 번 화면 기준 점검이 필요하다.
### 3.5 Android target 실사용 흐름 점검
- [x] 로그인 성공 후 `직원검색` 첫 화면으로 진입하는가
- [x] `검색 결과`와 기본 직원 목록이 표시되는가
- [x] 직원 상세 화면 진입이 되는가
- [x] 즐겨찾기 저장이 되는가
- [x] 전화/문자 버튼이 노출되는가
- [ ] 앱 재실행 후 세션 복원이 되는가
- [ ] 인증 만료 시 재로그인 유도 흐름으로 돌아가는가
현재 판단:
- 로그인 이후 핵심 탐색 흐름은 1차 확인했다.
- 세션 복원과 인증 만료 후 복귀 흐름은 실제 기기/에뮬레이터 기준으로 한 번 더 확인이 필요하다.
로그인 완료 가정 점검 절차:
1. 실기기 우선이면 `scripts/.env.android-device.local`, emulator fallback이면 `scripts/.env.android-emulator.local`에 API base와 테스트 번호가 준비되어 있는지 확인한다.
2. 필요 시 `TDC114_SMOKE_ASSUME_LOGGED_IN=1`을 사용해 테스트용 세션 bootstrap을 활성화한다.
3. `./scripts/integration_tests.sh`를 Android target 연결 상태에서 실행한다.
4. 앱이 로그인 화면이 아니라 `직원검색`으로 바로 진입하는지 확인한다.
5. 직원 목록, 즐겨찾기, 직원 상세, 전화/문자 버튼 노출까지 이어서 확인한다.
보조 메모:
- local runtime이 유효 세션을 내주지 못하면, 기능점검용으로 mock directory fallback을 사용한다.
- 이 경우에도 점검 목적은 "로그인 이후 앱 기능 확인"이며, 실제 승인 완료 검증을 대체하지는 않는다.
2026-07-06 추가 메모:
- local Baron SSO runtime에 테스트 번호 `010-9136-5338`용 identity/local user를 맞춘 뒤 host 기준 `./scripts/api-smoke.sh`는 다시 통과했다.
- 확인 완료:
- `phone-login` HTTP 200
- headless 로그인 시작 HTTP 200
- `link/poll` 첫 응답 `authorization_pending`
- 추가 완료:
- Windows emulator `emulator-5556 device` 복구
- Windows `5557 -> 127.0.0.1:5557` portproxy 추가
- `TDC114_ADB_CONNECT_ADDRESS=172.21.128.1:5557 ./scripts/integration_tests.sh` 통과
### 3.6 예외 시나리오 점검
- [ ] 미등록 번호 입력 시 generic 실패 안내가 보이는가
- [x] 만료된 `pendingRef`에서 poll 종료 처리가 자연스러운가
- [ ] polling 간격이 짧을 때 제한 또는 지연 안내가 되는가
- [ ] 로그인 성공 직후 directory API 401 없이 첫 화면이 유지되는가
현재 판단:
- 만료 `pendingRef`에 대한 backend 응답 자체는 확인했다.
- 다만 앱 화면에서 그 응답이 자연스럽게 보이는지, 미등록 번호와 polling 제한이 generic UX로 이어지는지는 아직 미완료다.
### 3.7 staging 반영 직전 확인
- [ ] 정확한 staging `TDC114_API_BASE`를 확보했는가
- [ ] staging에 headless 로그인 API와 `link/poll` API가 실제 반영되었는가
- [ ] 테스트 번호로 실제 링크 수신이 가능한가
- [ ] staging 로그 확인 위치와 담당자를 알고 있는가
- [x] 실패 시 롤백 또는 회귀 확인 방법을 문서화했는가
현재 판단:
- `https://sso.hmac.kr`는 현재 확인 기준으로 `tdc114plus` API base가 아니므로, 정확한 staging base는 아직 미확보다.
- staging 반영 실패 시 local 회귀 확인과 반영 전 점검 순서는 문서화되었지만, 실제 staging 담당자/로그 위치 확인은 남아 있다.
## 4. 현재 상태 메모
2026-07-06 현재 기준 메모:
- 앱과 backend의 기본 구현은 상당 부분 완료되었다.
- local runtime에서 API route와 기본 오류 응답은 확인했다.
- Android target 기준 로그인 화면과 직원검색 진입, 목록/상세/즐겨찾기/전화문자 버튼 노출은 1차 확인했다.
- local runtime에는 이제 테스트 번호 `010-9136-5338`의 identity/local user가 맞춰져 있어 최소 로그인 시작 단계 검증은 가능하다.
- 다만 실제 승인 완료 E2E는 여전히 staging 또는 동등 환경 검증이 필요하다.
## 5. 결과 기록 위치
점검 결과는 아래 문서에 누적한다.
- `docs/test-logs/2026-07-test-execution-log.md`
- 필요 시 `docs/daily-issues/YYYY-MM-DD.md`
@@ -0,0 +1,68 @@
# WSL 2주 점검 체크리스트
작성일: 2026-07-16
목적: `WSL ext4.vhdx` 비대화로 인한 리로드/재연결 문제를 줄이기 위해, 업무시작 시 2주마다 점검 알림을 띄운다.
## 기준
- 기준일: `2026-07-16`
- 주기: 14일
- 업무시작 진입점: `scripts/startup.sh`
- 점검 스크립트: `scripts/check-wsl-maintenance.sh`
## 동작 방식
`startup.sh`는 기동 초반에 `scripts/check-wsl-maintenance.sh status`를 실행한다.
- 아직 기한이 아니면 다음 예정일과 남은 일수를 로그에 출력한다.
- 14일이 지났으면 일반 PowerShell / 관리자 PowerShell 작업 순서를 로그에 출력한다.
- 실제 점검을 마친 뒤에는 아래 명령으로 완료 날짜를 기록한다.
```bash
./scripts/check-wsl-maintenance.sh record
```
## 사용 명령
상태 확인:
```bash
./scripts/check-wsl-maintenance.sh status
```
오늘 완료 처리:
```bash
./scripts/check-wsl-maintenance.sh record
```
기록 초기화:
```bash
./scripts/check-wsl-maintenance.sh reset
```
## 실제 점검 절차
일반 PowerShell:
```powershell
wsl --shutdown
```
관리자 PowerShell:
```powershell
diskpart
```
`DISKPART>` 안에서:
```text
select vdisk file="%LOCALAPPDATA%\Packages\CanonicalGroupLimited.Ubuntu_79rhkp1fndgsc\LocalState\ext4.vhdx"
attach vdisk readonly
compact vdisk
detach vdisk
exit
```
+73
View File
@@ -0,0 +1,73 @@
# 2026-07-03 작업 이슈 및 처리내역
## 목적
당일 작업 중 실제로 확인한 이슈, 처리 내용, 검증 결과, 남은 후속 작업을 기록한다.
## 이슈 1. 외부 org-context 직원 필터의 회사 subtree 누락
- 현상:
- `GET /api/v1/tdc114plus/directory/employees?tenantSlug=hanmac` 호출 시 최초에는 1명만 반환됐다.
- 원인:
- 외부 `org-context` 기준 `tenantSlug` 필터가 회사 하위 조직 subtree가 아니라 direct match 위주로 처리되고 있었다.
- 처리:
- Baron backend `tdc114plus_handler.go`에서 외부 직원 필터를 ancestor/subtree 기준으로 보정했다.
- 관련 handler test를 추가/보강했다.
- 검증:
- backend rebuild 후 `tenantSlug=hanmac` 결과가 `total=339`로 증가한 것을 실제 API로 확인했다.
- 후속:
- 실제 앱 초기 범위와 체감 성능 기준으로 추가 정합성 확인이 남아 있다.
## 이슈 2. 초기 범위를 회사보다 더 좁은 조직 slug로 축소 필요
- 현상:
- 일부 가족사는 직원 수가 1000명 이상일 수 있어 회사 단위 초기 조회도 무거울 수 있다.
- 판단:
- 조직도 예시의 `name`, `slug` 조합은 팀/부서 단위 조직 slug로 볼 수 있다.
- 처리:
- 앱에서 로그인 사용자 회사 범위 안에서 자기 전화번호/이름으로 본인을 다시 검색해 실제 조직 slug를 식별하도록 구현했다.
- 검증:
- 실제 smoke 로그인 사용자 `문형석 / +821091365338``tenantSlug=hanmac` 범위 재검색 시 조직 slug `is-3`, 조직명 `IS3`으로 식별됐다.
- `tenantSlug=is-3` 기준 실제 직원목록은 총 6명으로 확인됐다.
- 후속:
- 초기 조직 slug 식별 실패 시 회사 slug fallback이 계속 적절한지 추가 확인이 필요하다.
## 이슈 3. Android emulator integration test의 NDK/CMake license blocker
- 현상:
- Android integration test 실행 시 `ndk;28.2.13676358` license 미승인으로 `assembleDebug`가 실패했다.
- 처리:
- `scripts/flutter-docker.sh`에 Android SDK license 선행 승인 로직을 추가했다.
- Docker cache 아래 Android SDK `licenses`, `ndk`, `cmake` 디렉터리가 유지되도록 재사용 경로를 활용했다.
- 검증:
- 재실행 시 NDK/CMake license가 승인되고 실제 설치가 완료됐다.
- 이후 Android emulator integration test가 끝까지 통과했다.
- 후속:
- 첫 Android 빌드 시간이 여전히 길어 추가 warm-up 또는 캐시 최적화 여지는 있다.
## 이슈 4. 홈화면 직원목록 실제 노출 검증
- 목표:
- 전화번호 로그인 후 홈화면에 본인 팀 소속 직원목록이 실제로 표시되는지 확인한다.
- 처리:
- integration test에 실제 API 로그인 후 홈화면에서 `검색 결과`, `문형석`, `IS3` 노출을 확인하는 검증을 추가했다.
- 검증:
- Android emulator integration test에서 실제 API 로그인 후 `직원검색`, `검색 결과`, `문형석`, `IS3`가 노출되는 것을 확인했다.
- 결과적으로 홈화면 직원목록 노출 목표를 당일 기준 달성했다.
- 후속:
- 조직도 탭 표현과 정렬/요약 표시의 세부 UX 검토는 다음 작업 후보로 남는다.
## 이슈 5. 다음 출근 시 기동 확인 및 복구 절차 필요
- 배경:
- 퇴근 시 VS Code, 터미널, Android emulator를 모두 종료하면 다음 출근 시 어떤 순서로 기동 확인과 복구를 해야 하는지 빠르게 참고할 문서가 필요하다.
- 처리:
- `docs/checklist_morning_startup_runtime_2026-07-03.md` 문서를 추가했다.
- 포함 내용:
- Baron runtime 확인
- API smoke 확인
- Android emulator/device 인식 확인
- integration test 실행
- 실패 시 복구 순서
- 후속:
- 실제 다음 출근 시 이 문서로 재개하면서 부족한 부분이 있으면 보완한다.
@@ -0,0 +1,193 @@
# 2026-07-14 업무 인계 메모
작성 시각: 2026-07-14 16:54 KST
## 오늘 최종 상태
- 실기기 로그인 흐름은 Baron SSO 공식 정책에 맞춰 동작 확인했다.
- 사용자는 앱에서 전화번호 입력 후 문자 링크를 받는다.
- 문자 링크를 누르면 Chrome에서 Baron SSO 승인 완료 화면에 머문다.
- 사용자가 직접 TDC114PLUS 앱으로 돌아오면 앱이 저장된 pendingRef로 poll을 재개하고 직원검색 메인 화면에 진입한다.
- Baron SSO 정책상 문자 링크 클릭 후 Chrome이 자동으로 앱으로 돌아오는 기능은 현재 지원되지 않는다.
- 직원검색 화면의 직원/조직 정보 호출은 `tdc114plus-auth` 5001 서버의 `/api/v1/integrations/org-context`를 사용한다.
- 실기기에서 직원목록 표시 확인 완료:
- IS3 중복 뱃지 제거 확인
- 전화번호 `010-0000-0000` 형식 표시 확인
- 직원목록 표시 확인
## 오늘 발생한 주요 이슈
### 1. 로그인 승인 후 앱 복귀 UX
현상:
- 문자 링크 클릭 후 Chrome의 `로그인 승인 완료` 화면에 머물렀다.
- 사용자가 `로그인 창으로 이동하기` 버튼을 누르면 Baron SSO 로그인창/대시보드 흐름으로 이동해 앱 메인 진입 흐름과 맞지 않았다.
확인된 정책:
- Baron SSO 개발자 답변 기준, `/api/v1/auth/headless/link/init`에는 post-verify redirect 필드가 없다.
- SMS 링크를 누른 브라우저는 verify-only approver로 동작한다.
- 승인 완료 후 브라우저가 앱 callback/App Link로 자동 이동하는 정책은 없다.
현재 대응:
- 앱 문구를 “문자 승인 후 앱으로 돌아오면 자동 로그인됩니다” 흐름으로 맞췄다.
- 앱 복귀 시 저장된 pendingRef로 poll을 재개하도록 처리했다.
내일 확인할 것:
- 사용자가 앱 복귀 시 즉시 메인으로 들어가는지 반복 테스트한다.
- 자동 앱 복귀가 꼭 필요하면 Baron SSO 쪽 정책/기능 추가 요청 사안으로 분리한다.
### 2. `auth_provider_unavailable`
현상:
- 로그인 화면에서 `승인 상태 확인 실패: auth_provider_unavailable` 발생.
원인:
- 5001 `tdc114plus-auth` 서버가 Baron SSO `link/poll` 완료 후 OIDC redirect/consent/token exchange를 처리하는 구간에서 실패할 수 있었다.
- 이후 서버를 최신 소스 기준으로 재기동하고 로그를 직접 확인했다.
확인 로그:
- `link/init` 성공
- `link/poll` pending 반복
- `link/poll status=ok`
- `/consent` redirect 감지
- consent accept 성공
- callback URL에 code 포함
- token exchange 성공
결론:
- 최신 `tdc114plus-auth` 소스와 올바른 환경값으로 실행하면 login -> poll -> consent -> token exchange는 정상 동작한다.
### 3. 직원검색 화면 로딩 지속
현상:
- 직원검색 화면에 진입했지만 중앙 로딩만 표시되고 직원목록이 나오지 않았다.
확인:
- 5001 서버 로그에 `GET /api/v1/integrations/org-context`가 여러 번 찍혔다.
- 즉 앱이 직원/조직 API를 호출하지 않은 것이 아니라, 호출은 하고 있었다.
원인:
- 5001 auth 서버를 수동 재기동하면서 조직도 연동 키 환경값을 빠뜨렸다.
- 누락된 값:
- `BARON_ORG_CONTEXT_KEY_ID`
- `BARON_ORG_CONTEXT_KEY_SECRET`
조치:
- `scripts/.env.android-device.local`에 있는 값을 기준으로 5001 서버를 기동하도록 자동화했다.
- 새 스크립트 추가:
- `scripts/start-auth-server.sh`
- `scripts/startup.sh`에서 업무시작 시 `start-auth-server.sh --restart`를 자동 호출하도록 연결했다.
- `scripts/shutdown.sh`에서 5001 auth 서버도 종료하도록 연결했다.
- `scripts/.env.android-device.local`, staging env 파일 권한을 `600`으로 조정했다.
검증:
```bash
./scripts/start-auth-server.sh --restart
curl -fsSL --max-time 5 http://127.0.0.1:5001/health
```
응답:
```json
{"jwks":"ok","provider":"baron","status":"ok"}
```
실기기 화면:
- 직원검색 화면에서 직원목록 정상 표시 확인.
## 내일 업무 시작 순서
1. Windows 관리자 PowerShell에서 ADB portproxy/방화벽 상태 확인이 필요하면 기존 절차대로 확인한다.
2. Windows 일반 PowerShell에서 실기기 ADB 연결 확인:
```powershell
cd $env:LOCALAPPDATA\Android\Sdk\platform-tools
.\adb.exe devices
```
3. WSL에서 업무시작 기동:
```bash
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/startup.sh --auto --wait=40
```
4. 5001 auth 서버 확인:
```bash
curl -fsSL --max-time 5 http://127.0.0.1:5001/health
```
정상 기준:
```json
{"jwks":"ok","provider":"baron","status":"ok"}
```
5. 실기기 reverse 확인:
```bash
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/flutter-docker.sh adb -s R5CT42QTCNX reverse --list
```
필수:
```text
tcp:5000 tcp:5000
tcp:5001 tcp:5001
```
6. 앱 실행 후 확인:
- 로그인 세션이 남아 있으면 직원검색 화면 진입
- 직원목록이 바로 표시되는지 확인
- 로딩이 지속되면 5001 로그 확인
```bash
tail -n 120 logs/$(date +%F)/tdc114plus-auth.log
```
## 내일 우선 점검할 항목
- `startup.sh``start-auth-server.sh --restart`를 정상 호출하는지 확인한다.
- 직원검색 화면 진입 시 `GET /api/v1/integrations/org-context`가 5001 로그에 찍히는지 확인한다.
- 직원목록 로딩이 다시 멈추면 가장 먼저 아래를 확인한다:
- 5001 health
- `scripts/.env.android-device.local` 존재 여부
- `TDC114_BARON_KEY_ID`, `TDC114_BARON_KEY_SECRET` 값 누락 여부
- adb reverse `tcp:5001 tcp:5001`
## 오늘 변경된 주요 파일
- `scripts/start-auth-server.sh`
- 5001 `tdc114plus-auth` 로컬 서버 기동 스크립트.
- `scripts/.env.android-device.local`에서 조직도 키를 읽어 `BARON_ORG_CONTEXT_*`로 주입한다.
- `scripts/startup.sh`
- 업무시작 기동 시 `tdc114plus-auth` 5001 서버를 자동 기동하도록 연결.
- `scripts/shutdown.sh`
- 업무종료 시 5001 auth 서버도 종료하도록 연결.
- `app/lib/src/features/auth/presentation/login_screen.dart`
- 문자 승인 후 앱 복귀 안내 문구 반영.
- pendingRef 복원/poll 오류 표시 개선.
- `docs/00_policy_tdc114plus_screen_feature_2026-07-07.md`
- Baron SSO 공식 verify-only 정책과 앱 복귀 방식 반영.
## 주의 사항
- `scripts/.env.android-device.local`에는 실제 연동 키가 들어 있으므로 외부 공유 금지.
- 내일 APK를 다시 빌드할 때는 반드시 auth base define을 포함한다.
```bash
./scripts/flutter-docker.sh build apk --debug \
--dart-define=TDC114_API_BASE=http://127.0.0.1:5000 \
--dart-define=TDC114_AUTH_API_BASE=http://127.0.0.1:5001 \
--dart-define=TDC114_DIRECTORY_API_BASE=http://127.0.0.1:5000 \
--dart-define=TDC114_ORGANIZATION_API_BASE=http://127.0.0.1:5000 \
--dart-define=TDC114_ORG_CONTEXT_API_BASE=http://127.0.0.1:5001
```
- 직원검색의 실제 직원/조직 조회는 현재 5001 auth broker를 통해 `/api/v1/integrations/org-context`로 간다.
- 5000의 예전 `/api/v1/tdc114plus/employees` 류 경로는 현재 직원검색의 주 경로가 아니다.
@@ -0,0 +1,189 @@
# 2026-07-15 업무 인계 메모
작성 시각: 2026-07-15 KST
## 오늘 최종 상태
- 실기기 직원검색 화면에서 실제 프로필 사진 노출을 확인했다.
- 현재 프로필 이미지 정책은 아래 순서로 정리된 상태다.
1. 네이버웍스 프로필 사진
2. Baron SSO `members[].id` 기준 UUID 파일명 이미지
3. 앱 기본 아바타
- `tdc114plus-auth``GET /api/v1/profile-image``NAVER_WORKS -> BARON_UUID_R2 -> DEFAULT` 순서로 동작하도록 정리했다.
- 앱은 `GET /api/v1/profile-image`를 우선 호출하고, 응답 실패 또는 미발견 시 `https://baroncs.co.kr/employee_img/{uuid}.jpg` 경로를 보조 fallback으로 사용하도록 보강했다.
- 실기기 캡처 기준으로 직원목록의 여러 사용자가 기본 이니셜 원이 아니라 실제 사진으로 표시되는 것을 확인했다.
## 오늘 진행한 핵심 작업
### 1. 네이버웍스 1순위 경로 실검증
확인 내용:
- 서비스 계정 자격값을 최신값으로 다시 반영했다.
- 토큰 발급이 실제로 성공하는지 재검증했다.
- `GET /users/{userId}``GET /users/{userId}/photo`를 실제 호출해 `302`, `404` 동작을 다시 확인했다.
확인 결과:
- `thlee3@samaneng.com`
- 프로필 조회 `HTTP 200`
- 사진 조회 `HTTP 302`
- `khkang@samaneng.com`
- 프로필 조회 `HTTP 200`
- 사진 조회 `HTTP 404`
의미:
- 네이버웍스 사진이 있는 사용자는 1순위 경로로 바로 쓸 수 있다.
- 네이버웍스 사진이 없는 사용자는 2순위 UUID 이미지로 내려가면 된다.
### 2. Baron UUID 기반 2순위 경로 재확정
확인 내용:
- `org-context` 실응답의 `members[].id`가 실제 UUID 형식인지 다시 확인했다.
- 기존 CSV와 가족사 전체 응답을 대조한 매핑 결과를 기준으로 UUID 파일명 정책을 유지하기로 정리했다.
- 외부 공개 경로는 `https://baroncs.co.kr/employee_img/{uuid}.jpg` 규칙으로 본다.
현재 판단:
- 2순위 식별자는 이메일 local-part나 해시명이 아니라 Baron UUID로 보는 것이 가장 안정적이다.
- 프로필 이미지 전용 DB는 운영 기본안으로 채택하지 않고 보류 대안으로만 남긴다.
### 3. `tdc114plus-auth` 프로필 이미지 endpoint 보강
오늘 반영한 내용:
- `GET /api/v1/profile-image`에 네이버웍스 1순위 조회를 연결했다.
- 네이버웍스에서 사진이 없으면 Baron UUID 기준 공개 이미지 경로를 2순위로 판단하게 정리했다.
- 기존 PostgreSQL 매핑 경로는 최후 예비안 수준으로만 남겼다.
실검증 결과:
- `GET /api/v1/profile-image?email=thlee3@samaneng.com`
- `found=true`
- `source=NAVER_WORKS`
- `GET /api/v1/profile-image?email=khkang@samaneng.com`
- `found=true`
- `source=BARON_UUID_R2`
의미:
- 서버 레벨에서는 1순위/2순위 fallback이 실제 응답으로 이미 확인된 상태다.
### 4. 앱 프로필 이미지 resolver 보강
오늘 반영한 내용:
- 앱은 다시 `tdc114plus-auth /api/v1/profile-image`를 우선 호출하도록 정리했다.
- 응답 실패, 미발견, 또는 세션 문제 상황에서는 UUID 형식 `employee.id`가 있으면 공개 UUID 이미지 경로를 직접 fallback 하도록 보강했다.
- 관련 단위 테스트를 추가하고 통과시켰다.
테스트 결과:
- `app/test/directory/profile_image_api_client_test.dart`
- auth endpoint 경유 성공
- 이메일 없음 시 기본 null 처리
- auth 미발견 시 UUID 공개 경로 fallback
- 테스트 통과 확인
### 5. 실기기 반영 및 화면 확인
실행 내용:
- auth 서버 기동 상태 확인
- APK 빌드 및 실기기 설치
- 공기계에서 직원검색 화면 재진입
- 실기기 화면 캡처로 실제 사진 표시 여부 확인
최종 확인:
- 직원검색 목록에서 여러 사용자 사진이 실제로 표시됨
- 오늘 목표였던 “실기기에서 사진 뜨는지 확인”은 완료
## 오늘 발생한 보조 이슈
### 1. 실기기 재실행 중 bootstrap 일시 실패
현상:
- 일부 재실행 구간에서 `127.0.0.1:5000` bootstrap 연결 실패 메시지가 한 차례 있었다.
현재 판단:
- 이후 권한 포함 재실행에서는 정상 bootstrap 후 앱이 다시 올라왔다.
- 오늘 최종 결과는 실기기 사진 표시 성공으로 본다.
### 2. Docker/ADB 권한 및 대기 시간 이슈
현상:
- 일부 재실행 또는 조회 명령은 Docker 권한/ADB 응답 지연 때문에 중간 확인이 매끄럽지 않았다.
현재 판단:
- 기능 자체의 blocker는 아니었다.
- 최종적으로 실기기 캡처까지 확보했으므로 오늘 작업 결론에는 영향 없다.
## 오늘 변경 및 반영한 주요 파일
- `app/lib/src/features/directory/data/profile_image_api_client.dart`
- auth endpoint 우선 호출
- UUID 직접 fallback 추가
- debug 로그 보강
- `app/test/directory/profile_image_api_client_test.dart`
- UUID fallback 관련 테스트 추가
- `scripts/start-auth-server.sh`
- 네이버웍스 env를 함께 읽어 auth 서버 기동 시 반영되도록 정리
- `docs/00_guide_tdc114plus_profile_image_identifier_mapping_2026-07-14.md`
- 오늘 실검증 결과와 실기기 성공 결과 반영
- `docs/00_guide_tdc114plus_work_progress_timetable_2026-07-02.md`
- 오늘 진행 상태와 다음 검증 포인트 반영
## 내일 바로 이어서 볼 항목
1. 현재 화면에 뜬 각 프로필 사진이 `NAVER_WORKS`인지 `BARON_UUID_R2`인지 source별로 구분 검증한다.
2. 직원검색뿐 아니라 직원 상세/조직도 화면에서도 같은 규칙으로 사진이 표시되는지 확인한다.
3. `DEFAULT` 케이스도 샘플로 잡아 기본 아바타 fallback이 자연스러운지 확인한다.
4. 필요 시 프로필 이미지 관련 debug 로그를 더 짧게 정리하거나 제거한다.
## 내일 업무 시작 순서
1. auth 서버 상태 확인
```bash
curl -fsSL --max-time 5 http://127.0.0.1:5001/health
```
2. 실기기 reverse 상태 확인
```bash
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/flutter-docker.sh adb -s R5CT42QTCNX reverse --list
```
3. 필요 시 auth 서버 재기동
```bash
./scripts/start-auth-server.sh --restart --env-file=/home/ubuntu/workspace/tdc114plus/scripts/.env.android-device.local
```
4. 필요 시 실기기 앱 재실행
```bash
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 TDC114_FLUTTER_DEVICE_ID=R5CT42QTCNX TDC114_SMOKE_ENV_FILE=scripts/.env.android-device.local ./scripts/manual-postlogin-run.sh
```
5. 첫 확인 포인트
- 직원검색 목록 진입
- 사진이 계속 노출되는지 확인
- source별 구분 검증 대상으로 사용자 샘플 확보
## 오늘 결론
- 오늘 작업의 핵심 목표였던 “실기기에서 프로필 사진이 실제로 뜨는지 확인”은 완료했다.
- 현재 구조는 `네이버웍스 -> Baron UUID 이미지 -> 기본 아바타` 정책과 실제 코드/실기기 화면이 서로 맞물리는 상태다.
- 내일은 새로운 구현보다, 오늘 붙인 구조를 source별로 더 명확히 검증하고 화면 범위를 넓히는 단계로 이어가면 된다.
@@ -0,0 +1,83 @@
# 2026-07-16 아침 시작 체크리스트
목적: 오늘 작업을 바로 이어가기 위해, 가장 먼저 확인할 항목만 짧게 정리한다.
## 1. 먼저 볼 기준
- 현재 프로필 이미지 정책:
1. 네이버웍스
2. Baron UUID 이미지
3. 기본 아바타
- 전날 최종 상태:
- 실기기 직원검색 화면에서 실제 프로필 사진 노출 확인 완료
- 앱은 `tdc114plus-auth /api/v1/profile-image` 우선 호출
- 실패 또는 미발견 시 `https://baroncs.co.kr/employee_img/{uuid}.jpg` fallback 사용
## 2. 아침 시작 순서
### 1) auth 서버 상태 확인
```bash
curl -fsSL --max-time 5 http://127.0.0.1:5001/health
```
정상 기준:
```json
{"jwks":"ok","provider":"baron","status":"ok"}
```
### 2) reverse 상태 확인
```bash
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/flutter-docker.sh adb -s R5CT42QTCNX reverse --list
```
필수 확인:
```text
tcp:5000 tcp:5000
tcp:5001 tcp:5001
```
### 3) 필요 시 auth 서버 재기동
```bash
./scripts/start-auth-server.sh --restart --env-file=/home/ubuntu/workspace/tdc114plus/scripts/.env.android-device.local
```
### 4) 필요 시 실기기 앱 재실행
```bash
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 TDC114_FLUTTER_DEVICE_ID=R5CT42QTCNX TDC114_SMOKE_ENV_FILE=scripts/.env.android-device.local ./scripts/manual-postlogin-run.sh
```
## 3. 첫 확인 포인트
- 직원검색 목록 진입되는가
- 사진이 계속 표시되는가
- 어떤 사용자가 `NAVER_WORKS`인지, 어떤 사용자가 `BARON_UUID_R2`인지 샘플을 잡을 수 있는가
## 4. 오늘 바로 이어서 할 일
1. 실기기 기준 `NAVER_WORKS` 케이스 확인
2. 실기기 기준 `BARON_UUID_R2` 케이스 확인
3. `DEFAULT` 기본 아바타 케이스 확인
4. 직원 상세/조직도 화면에서도 같은 규칙 유지 확인
## 5. 문제 생기면 먼저 볼 것
- `5001 health` 정상 여부
- `adb reverse``5000`, `5001` 둘 다 있는지
- `scripts/.env.android-device.local` 경로와 값 누락 여부
- 필요 시 auth 로그 확인
```bash
tail -n 120 logs/$(date +%F)/tdc114plus-auth.log
```
## 6. 참고 문서
- [2026-07-15_work_handoff.md](/home/ubuntu/workspace/tdc114plus/docs/daily-issues/2026-07-15_work_handoff.md)
- [00_guide_tdc114plus_profile_image_identifier_mapping_2026-07-14.md](/home/ubuntu/workspace/tdc114plus/docs/00_guide_tdc114plus_profile_image_identifier_mapping_2026-07-14.md)
- [00_guide_tdc114plus_work_progress_timetable_2026-07-02.md](/home/ubuntu/workspace/tdc114plus/docs/00_guide_tdc114plus_work_progress_timetable_2026-07-02.md)
@@ -0,0 +1,224 @@
# 2026-07-16 업무 인계 메모
작성 시각: 2026-07-16 KST
## 오늘 최종 상태
- 실기기 로그인은 다시 정상 진행됐다.
- 문자 링크 승인 후 앱 복귀 시 직원검색 목록이 다시 표시되는 것까지 확인했다.
- 오늘 최종 복구 기준은 `로그인 성공 -> 직원검색 목록 표시 성공`이다.
- 다만 프로필 사진 1순위/2순위/3순위 화면 검증은 아직 오늘 완료 범위에 포함하지 않는다.
## 오늘 발생한 주요 이슈와 원인
### 1. 로그인 후 직원검색이 다시 무너지는 문제
현상:
- 로그인 링크 발송은 되지만 앱 진입 후 직원검색 화면이 비거나 로딩만 지속됐다.
- 잠시 후 `로그인이 만료되었거나 권한을 확인할 수 없습니다.` 화면으로 떨어졌다.
중간에 확인한 사실:
- 앱 SharedPreferences 안에는 세션 토큰이 실제로 저장되고 있었다.
- 저장된 토큰은 Baron access token이 아니라 `tdc114plus-auth`가 발급한 app session token 형식이었다.
- 저장된 user 정보는 아래처럼 fallback 값이었다.
- `id = baron-user`
- `name = Baron User`
- `tenantSlug = ""`
의미:
- 세션 토큰이 전혀 저장되지 않는 문제는 아니었다.
- 다만 user 메타데이터는 충분하지 않았고, 동시에 org-context 중계 경로도 깨져 있었다.
### 2. 핵심 장애 원인: 5001 auth broker의 org-context self-recursion
현상:
- `tdc114plus-auth` 로그에 `/api/v1/integrations/org-context` 요청이 들어온 뒤,
같은 5001 서버가 다시 자기 자신의 `/api/v1/integrations/org-context`를 호출하는 패턴이 반복됐다.
- 내부 재호출 요청에는 `Authorization`, `X-App-Session` 헤더가 없어서 결국 unauthorized 흐름으로 무너졌다.
실제 확인값:
- 실행 중인 5001 프로세스 환경변수에서 아래를 직접 확인했다.
```text
BARON_ORG_CONTEXT_BASE_URL=http://127.0.0.1:5001
```
즉:
- 원래 의도는 `https://sadmin.hmac.kr`를 upstream으로 호출해야 하는데,
- 실제 실행 중 프로세스는 자기 자신 `http://127.0.0.1:5001`을 upstream으로 잡고 있었다.
근본 원인:
- `scripts/start-auth-server.sh`
`TDC114_AUTH_UPSTREAM_ORG_CONTEXT_API_BASE` 값을 env 파일 `source` 전에 먼저 읽고 있었다.
- 그래서 `scripts/.env.android-device.local` 안에
`TDC114_AUTH_UPSTREAM_ORG_CONTEXT_API_BASE=https://sadmin.hmac.kr`
가 있어도 반영되지 않았다.
- 그 결과 fallback으로 `TDC114_ORG_CONTEXT_API_BASE=http://127.0.0.1:5001`를 사용하면서 self-recursion이 발생했다.
### 3. 오늘 복구 조치
수정 파일:
- `scripts/start-auth-server.sh`
수정 내용:
- `UPSTREAM_ORG_CONTEXT_BASE` 읽는 위치를 env 파일 `source` 뒤로 옮겼다.
- 이후 5001 auth broker를 재기동했다.
재기동 후 직접 확인한 실행 환경:
```text
PORT=5001
APP_SESSION_SECRET=tdc114plus-dev-session
BARON_ORG_CONTEXT_BASE_URL=https://sadmin.hmac.kr
BARON_ORG_CONTEXT_TENANT_SLUG=hanmac-family
```
결과:
- self-recursion 원인이 제거됐다.
- 다시 로그인 후 실기기 직원검색 목록이 정상 표시됐다.
## 오늘 추가로 확인한 기술 메모
### 1. `baron-user / Baron User` 생성 위치
파일:
- `/home/ubuntu/workspace/tdc114plus-auth/cmd/server/main.go`
확인 내용:
- `link/poll` 완료 후 `poll.User`에 값이 없을 때 fallback으로 아래 값이 채워진다.
- `baron-user`
- `Baron User`
현재 판단:
- 이 값은 오늘 새로 생긴 회귀가 아니라 기존 구조였다.
- 오늘의 실장애 원인은 이것 자체보다 `org-context upstream 오배선`이었다.
### 2. `tenantSlug` 누락 구조는 후속 개선 대상
현재 확인 결과:
- `tdc114plus-auth``link/poll` 응답 user 모델에는 `tenantSlug`, `tenantId`, `tenantName`이 없다.
- 앱은 세션 user의 `tenantSlug`가 비어 있으면 후속 조직도/초기 선택 계산에서 fallback을 더 많이 타게 된다.
중요 판단:
- 오늘 직원검색이 완전히 무너진 직접 원인은 self-recursion이었다.
- 하지만 `tenantSlug` 누락 구조는 다음 작업에서 별도로 정리해야 한다.
## 오늘 수정/추가한 파일
- `scripts/start-auth-server.sh`
- org-context upstream env 읽는 순서를 수정했다.
- `app/lib/src/core/config/app_environment.dart`
- 빌드 시각 define 표시용 항목을 유지했다.
- `app/lib/src/features/auth/presentation/login_screen.dart`
- 실기기에서 최신 APK 식별을 위해 빌드 시각 표시를 유지했다.
## 확인 완료한 테스트
1. 5001 health 확인
```bash
curl -i --max-time 5 http://127.0.0.1:5001/health
```
결과:
```json
{"jwks":"ok","provider":"baron","status":"ok"}
```
2. 실행 중 5001 프로세스 환경변수 확인
- `BARON_ORG_CONTEXT_BASE_URL=https://sadmin.hmac.kr` 반영 확인
3. 실기기 세션 저장 확인
- SharedPreferences 안에 app session token 저장 확인
- user fallback 값 저장 확인
4. 실기기 재로그인 후 직원검색 확인
- 문자 링크 승인 성공
- 앱 복귀 성공
- 직원검색 목록 표시 성공
## 내일 업무 시작 순서
1. 전일 인계 문서 먼저 확인
- `docs/daily-issues/2026-07-16_work_handoff.md`
2. 5001 auth broker health 확인
```bash
curl -fsSL --max-time 5 http://127.0.0.1:5001/health
```
3. 실행 중 5001 프로세스 upstream 값 확인
```bash
ss -ltnp '( sport = :5001 )'
tr '\0' '\n' < /proc/<PID>/environ | rg 'BARON_ORG_CONTEXT_BASE_URL|BARON_ORG_CONTEXT_TENANT_SLUG'
```
정상 기준:
```text
BARON_ORG_CONTEXT_BASE_URL=https://sadmin.hmac.kr
BARON_ORG_CONTEXT_TENANT_SLUG=hanmac-family
```
4. 실기기 ADB 연결 및 reverse 확인
```bash
ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/flutter-docker.sh adb -s R5CT42QTCNX reverse --list
```
5. 앱 재로그인 후 첫 확인
- 직원검색 목록이 바로 뜨는지
- 30초 뒤에도 만료 화면으로 떨어지지 않는지
## 내일 우선 점검할 항목
1. `org-context` self-recursion이 정말 재발하지 않는지 먼저 확인한다.
2. `tenantSlug` 누락 구조를 `tdc114plus-auth` 응답 모델 기준으로 정리한다.
3. 프로필 사진 우선순위 검증을 아래 순서로 이어간다.
- NAVER_WORKS
- BARON_UUID_R2
- DEFAULT
4. 직원검색 외에 조직도/상세/즐겨찾기 화면도 같은 세션 상태에서 유지되는지 확인한다.
## 보안상 주의사항
- 아래 값들은 문서에 실제 값을 남기지 않는다.
- NAVER WORKS 서비스 계정 자격값
- Baron org-context key id / secret
- private key 경로 및 원문
- `scripts/.env.android-device.local`
- `secrets/naver_works_service_account.local.env`
위 두 파일은 계속 비공개 로컬 파일로만 유지한다.
## 내일 첫 판단 기준
- 내일 첫 목표는 “또 고치는 것”이 아니라 “오늘 복구한 5001 upstream 상태가 유지되는지 먼저 검증”이다.
- 그 상태가 유지되면 사진 정책 검증으로 넘어가고,
- 유지되지 않으면 가장 먼저 `start-auth-server.sh`와 실행 중 프로세스 환경변수부터 다시 본다.
@@ -0,0 +1,305 @@
# 2026-07-20 업무 인계 메모
작성 시각: 2026-07-20 KST
## 오늘 핵심 결론
- `tdc114plus` 앱과 로컬 `tdc114plus-auth:5001` 연결은 정상 확인했다.
- 실기기 `adb reverse tcp:5001 tcp:5001`도 정상 확인했다.
- 앱에서 `link/init` 호출 후 `pendingRef` 생성, 이후 `link/poll` 반복까지 서버 로그로 확인했다.
- 즉 오늘 로그인 막힘의 핵심은 `앱 <-> 로컬 중계서버` 구간이 아니라 `Baron SSO 문자 링크 실제 발송/SMS 연계 구간`으로 판단했다.
## 오늘 확인한 최종 상태
### 1. 로컬 auth broker 상태
정상 확인값:
```bash
curl -i --max-time 5 http://127.0.0.1:5001/health
```
응답:
```json
{"jwks":"ok","provider":"baron","status":"ok"}
```
의미:
- 로컬 `tdc114plus-auth` 5001은 떠 있었다.
- 최소한 health 기준으로는 죽어 있지 않았다.
### 2. 실기기 reverse 상태
사용자 확인 결과:
```text
adb reverse --list
UsbFfs tcp:5001 tcp:5001
```
의미:
- 실기기에서 `127.0.0.1:5001`로 가는 호출은 PC 로컬 5001로 붙는 상태였다.
- 따라서 실기기에서 auth broker 접속이 안 되는 상태는 아니었다.
### 3. 앱이 실제로 5001에 요청을 보내는지 확인
서버 로그에서 아래를 직접 확인했다.
```text
linkInit body={"phoneNumber":"01091365338","device":{"platform":"android","appVersion":"0.1.0","deviceName":"android"}}
```
그리고 이어서:
```text
POST /api/v1/auth/link/init
POST /api/v1/auth/link/poll
```
반복 확인했다.
의미:
- 앱에서 로그인 버튼을 눌렀을 때 요청이 실제로 로컬 auth broker까지 도달했다.
- `pendingRef` 생성도 정상 진행됐다.
### 4. 앱 내부 pending 상태 저장 확인
실기기 SharedPreferences에서 아래 항목을 직접 확인했다.
- `flutter.tdc114plus.auth.pending.ref`
- `flutter.tdc114plus.auth.pending.expiresAt`
의미:
- 앱이 로그인 요청 직후 pending 상태를 저장하지 못하는 문제는 아니었다.
- 앱 내부 상태 저장까지는 정상으로 봐도 된다.
## 오늘 발생한 실제 장애
현상:
- 앱에서는 `승인 대기 중` 화면으로 넘어간다.
- 하지만 사용자 휴대폰에는 실제 문자 링크가 도착하지 않는다.
- 재전송 시도 후에도 동일하다.
중요 판단:
- 앱 요청 실패 아님
- 로컬 auth broker 연결 실패 아님
- `adb reverse` 누락 문제 아님
- 현재 가장 의심되는 구간은 Baron SSO 쪽 `문자 링크 실제 발송 처리` 또는 그 뒤 SMS 연계 구간이다.
## 오늘 중간에 있었던 혼선 정리
### 1. `auth_provider_unavailable`
한 시점에는 앱에 아래 문구가 보였다.
```text
로그인 링크 요청 실패: auth_provider_unavailable
```
이때 직접 확인한 사실:
- 같은 시각 `sso.hmac.kr /oidc/oauth2/auth``502 Bad Gateway`를 반환하던 구간이 있었다.
- 이후 재확인 시 다시 `302 Location: /login?login_challenge=...`로 정상 응답했다.
의미:
- Baron SSO upstream authorization 시작점이 한때 불안정했다.
- 하지만 이후 복구된 뒤에도 최종적으로는 `문자 미수신` 문제가 남았다.
### 2. callback URL 혼선
아래 주소는 현재 직접 열면 `404 page not found`가 보인다.
```text
https://114-auth.hmac.kr/api/v1/auth/oidc/callback
```
정리:
- 이 URL은 브라우저에서 직접 여는 진입 URL이 아니라 OIDC 최종 code 수신용 callback 경로다.
- 현재 `문자 미수신` 문제의 직접 원인으로 확정한 상태는 아니다.
- 다만 라우트 정합성은 후속 점검 대상으로 계속 유지한다.
## 오늘 외부 전달용 요약
Baron SSO 개발자에게 전달할 핵심은 아래였다.
```text
- 신규앱 -> 로컬 tdc114plus-auth(5001) 요청 정상
- adb reverse 정상
- auth 서버 로그상 link/init, pendingRef 생성, link/poll 반복 정상
- 그런데 실제 문자 링크가 사용자 휴대폰에 도착하지 않음
- 따라서 Baron SSO 쪽 문자 링크 실제 발송 처리 또는 SMS 연계 구간 확인 필요
```
## 오늘 기준 남아 있는 작업
### 1. 문자 미수신 이슈 답변 대기
- Baron SSO 개발자 확인 결과를 받아야 한다.
- 그 전까지는 앱/로컬 중계서버 쪽에서 더 고쳐도 문자 미수신 문제를 끝낼 수 없다.
### 2. 프로필 사진 route 상태 정리
현재 확인 상태:
- 앱은 `/api/v1/profile-image`를 먼저 시도한다.
- 하지만 현재 5001 서버는 해당 route에 `404`를 반환한다.
의미:
- 사진 1순위/2순위/3순위 실기기 검증은 아직 시작 조건이 완전히 갖춰지지 않았다.
### 3. 로그인 화면 상태 문구 정리
관찰된 화면:
- 실패 문구
- 승인 대기 박스
- 재전송 카운트
이 조합이 시점별로 다르게 보였다.
후속 과제:
- 요청 시작 시 이전 에러 문구를 항상 지우는지
- pending 복원 시 에러 상태를 같이 끌고 오지 않는지
- 문자 미수신과는 별개로 화면 상태 표현이 꼬이지 않는지
## 외부 답변 대기 중 내부 진행 순서
문자 발송 구간 답변이 오기 전에도 아래 순서로는 내부 작업을 진행할 수 있다.
1. `tdc114plus-auth` 응답 메타데이터 정합성 재확인
- `tenantSlug`, `tenantId`, `tenantName`, `department`, `grade`, `position`, `jobTitle`
- 앱이 실제로 어떤 필드를 저장하고 소비하는지 확인
2. 로그인 화면 상태 표현 정리
- `실패 문구``승인 대기 UI`가 동시에 남지 않도록 정리
- pending 복원 시 문구 초기화 조건 확인
3. `/api/v1/profile-image` route 복구 준비
- 현재 앱 호출 형식 고정
- auth 서버 route 부재 상태 문서화
- 복구 시 필요한 응답 형태 확정
4. 프로필 사진 검증 대상 사용자 표 준비
- NAVER WORKS 1순위 예상 사용자
- UUID 이미지 2순위 예상 사용자
- DEFAULT 3순위 예상 사용자
5. `baron-sso-tdc114plus-api` 제거 마이그레이션 표 세분화
- 남은 기능이 진짜 필요한지
- `tdc114plus-auth`에 흡수할지
- 완전히 제거할지 분류
## 2026-07-20 메타데이터 정합성 재확인 결과
확인 결과:
- `tdc114plus-auth`가 내보내는 사용자 메타데이터 JSON 키와
- `tdc114plus` 앱이 저장/사용하는 JSON 키는 현재 서로 맞는다.
확인한 키:
- `tenantId`
- `tenantName`
- `tenantSlug`
- `department`
- `grade`
- `position`
- `jobTitle`
정리:
1. 중계서버 출력
- `tdc114plus-auth/cmd/server/main.go`
- `readUser(...)`가 위 항목들을 읽는다.
- `issueAppSessionToken(...)`가 같은 키명으로 JWT `user` 클레임에 넣는다.
2. 앱 수신/저장
- `app/lib/src/features/auth/domain/auth_models.dart`
- `LoginUser.fromJson(...)`가 같은 키명으로 파싱한다.
- `app/lib/src/features/auth/data/auth_session_store.dart`
- 세션 저장 시 `response.user.toJson()` 그대로 SharedPreferences에 저장한다.
3. 앱 소비 위치
- `app/lib/src/features/directory/presentation/directory_screen.dart`
- `tenantSlug`가 있으면 초기 회사 선택에 바로 사용한다.
- `tenantSlug`가 비어 있으면 조직도 조회 fallback으로 현재 사용자 소속을 다시 찾는다.
- `department`가 있으면 초기 부서 고정에도 바로 사용한다.
- `profile_image_api_client.dart`에서도 `tenantSlug`/`tenantName`를 회사코드 추정에 사용한다.
현재 판단:
- 구조적 키 mismatch 문제는 아니다.
- 진짜 남은 위험은 `Baron upstream 응답에서 값이 비어 들어오는 경우`다.
- 특히 `tenantSlug`가 비면 앱은 fallback으로 버티지만, 초기 선택과 사진 1차 lookup 정확도는 떨어질 수 있다.
## 2026-07-20 프로필 사진 검증 대상 표
현재 문서와 과거 실검증 이력을 기준으로, source별 우선 확인 대상은 아래처럼 정리한다.
| 우선순위 | 예상 source | 사용자 | 근거 | 현재 상태 |
| --- | --- | --- | --- | --- |
| 1순위 | `NAVER_WORKS` | `thlee3@samaneng.com` | 2026-07-15 네이버웍스 사진 조회 `HTTP 302` 확인 이력 | route 복구 후 실기기 재검증 필요 |
| 2순위 | `BARON_UUID_R2` | `khkang@samaneng.com` | 2026-07-15 네이버웍스 사진 조회 `HTTP 404`, UUID 공개 이미지 fallback 이력 | route 복구 후 실기기 재검증 필요 |
| 3순위 | `DEFAULT` | 미확정 | 실제 운영 사용자 중 `네이버웍스 없음 + UUID 이미지 없음` 샘플을 아직 확정하지 못함 | 샘플 대상 추가 선정 필요 |
현재 판단:
- 1순위와 2순위는 과거 실검증 이력이 있는 샘플이 이미 있다.
- 3순위는 억지로 추정하지 말고, route 복구 후 실제 미보유 샘플을 한 명 확정하는 것이 안전하다.
## 2026-07-20 `/api/v1/profile-image` 복구 진행 결과
이번 복구에서 반영한 범위:
- `tdc114plus-auth``GET /api/v1/profile-image` route를 다시 등록했다.
- 현재 복구 로직은 아래 순서로 동작한다.
1. `NAVER_WORKS`
2. `BARON_UUID_R2`
3. `DEFAULT`
구체 복구 내용:
- 네이버웍스 서비스 계정 env가 있으면 `GET /users/{email}/photo``302/404` 규칙을 사용한다.
- 네이버웍스에서 사진이 없으면 `org-context`에서 email 기준 Baron UUID를 찾는다.
- UUID가 있으면 `https://baroncs.co.kr/employee_img/{uuid}.jpg` 공개 경로 존재 여부를 확인한다.
- 둘 다 없으면 `found=false`, `source=DEFAULT`를 반환한다.
검증 결과:
- `go test ./cmd/server` 통과
- 로컬 5001 재기동 완료
- 무인증 호출 기준 이전 `404 page not found`가 아니라 아래처럼 바뀐 것을 확인했다.
```json
{"error":"unauthorized","message":"앱 세션을 확인하세요."}
```
현재 판단:
- route 자체는 현재 기동 서버에 복구됐다.
- 즉 이전처럼 endpoint 자체가 없는 상태는 아니다.
- 다음 실제 확인은 `로그인된 앱 세션` 상태에서 1순위/2순위/3순위 화면 검증으로 넘어가면 된다.
## 내일 또는 다음 작업 시작 시 우선 순서
1. `5001 health` 먼저 확인
2. `adb reverse --list` 먼저 확인
3. 앱에서 `link/init` 로그가 실제로 찍히는지 확인
4. 문자 수신 여부를 먼저 확인
5. 문자 미수신이면 앱 수정으로 우회하지 말고 Baron SSO 답변 상태부터 확인
6. 문자 문제가 외부에서 정리되면 그 다음에 프로필 사진 검증으로 넘어간다
## 한 줄 요약
2026-07-20 기준 로그인 막힘의 핵심은 `앱이 요청을 못 보내는 문제`가 아니라 `요청은 정상인데 실제 문자 링크가 사용자에게 도착하지 않는 문제`다.
+40
View File
@@ -0,0 +1,40 @@
# Daily Handoff Policy
## 목적
매일 업무 시작 시 전날 작업 흐름, 남은 이슈, 테스트 상태를 먼저 확인해서 같은 문제를 반복하지 않도록 한다.
## 필수 규칙
1. 업무 시작 전 `docs/daily-issues/`의 최신 전일 작업 인계 MD 파일을 먼저 확인한다.
2. `scripts/startup.sh`는 런타임 기동 전에 최신 전일 인계 문서를 로그에 출력한다.
3. 전일 인계 문서가 없으면 기본적으로 startup을 중단한다.
4. 예외 복구 상황에서만 아래 환경값으로 강제 진행할 수 있다.
```bash
TDC114_REQUIRE_DAILY_HANDOFF=false ./scripts/startup.sh --auto --wait=40
```
## 작성 규칙
오늘 업무 종료 전에는 반드시 아래 내용을 포함한 MD 파일을 남긴다.
- 오늘 최종 상태
- 오늘 발생한 주요 이슈와 원인
- 오늘 수정/추가한 파일
- 확인 완료한 테스트
- 내일 업무 시작 순서
- 내일 우선 점검할 항목
- 보안상 공유하면 안 되는 값 또는 주의사항
## 파일명
```text
YYYY-MM-DD_work_handoff.md
```
예:
```text
2026-07-14_work_handoff.md
```
@@ -145,6 +145,21 @@ lib/
6. Gitea Actions 또는 수동 빌드 절차를 정리한다.
7. 개발 중간 산출물 기준 APK 배포 방식을 결정한다.
### 3.8 Android emulator / WSL ADB 연동 기준
Windows Android Studio emulator를 WSL 또는 Docker 기반 Flutter CLI에서 사용할 때는 별도 ADB 연동 정책을 따른다.
- 정책 문서: `docs/troubleshooting/policy_android_studio_wsl_adb_2026-07-03.md`
- 실행 기록: `docs/troubleshooting/android-studio-wsl-adb-timetable-260703.md`
- 통합테스트 시나리오: `docs/scenario_android_emulator_device_integration_test_2026-07-03.md`
핵심 기준:
- `adb -a -P 5037 nodaemon server`는 1차 시도만 한다.
- `10048` bind 실패가 재현되면 즉시 Windows `portproxy` 방식으로 전환한다.
- Android emulator에서 host API는 `127.0.0.1`이 아니라 `10.0.2.2`를 사용한다.
- Docker Flutter에서 Windows ADB를 사용할 때는 `ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037`을 명시한다.
## 4. 작업 원칙
- 1차 개발은 전화번호부/조직도 기본 기능 완성에 집중한다.
@@ -158,7 +173,7 @@ lib/
`tdc114plus` 저장소 골격이 갖춰지면 아래 문서를 복사 이관한다.
- `docs/tdc114plus-development-decision-brief-2026-07-01.md`
- `docs/tdc114plus-development-environment-setup-plan-2026-07-02.md`
- `docs/guide_tdc114plus_development_decision_brief_2026-07-01.md`
- `docs/dev_env_tdc114plus_setup_plan_2026-07-02.md`
이관 후 Baron SSO 저장소의 문서는 회의 및 초기 검토 기록으로 보존한다.
@@ -0,0 +1,238 @@
# Baron Org Context API 연동 참고
작성일: 2026-07-03
목적: `tdc114plus` 개발 중 Baron SSO 계열 조직/사용자 데이터를 어떤 API로 조회하는지, 인증 방식은 무엇인지, 실제 호출 예시와 응답 구조는 어떠한지 빠르게 참고할 수 있도록 정리한다.
## 1. 결론
`tdc114plus`에서 참고할 Baron 조직도 API는 아래 둘 중 하나처럼 보일 수 있다.
- 공개 공유링크 방식: `GET /api/v1/public/orgchart?token=...`
- API Key 방식: `GET /api/v1/integrations/org-context`
실제 확인 결과, 팀에서 전달받은 값은 `public/orgchart``token`이 아니라 `integrations/org-context``X-Baron-Key-ID`, `X-Baron-Key-Secret` 조합이다.
즉 현재 기준의 실제 연동 대상은 아래 API다.
```http
GET https://sadmin.hmac.kr/api/v1/integrations/org-context
X-Baron-Key-ID: {key id}
X-Baron-Key-Secret: {key secret}
```
2026-07-10 기준:
- 실제 배포 전까지 Baron SSO API 참고 기준은 staging `https://sadmin.hmac.kr/api/docs#/`다.
- Baron SSO가 로그인 성공 후 조직도 API 호출용 ID/Secret을 내려주는 기능은 아직 미개발이다.
- 앱은 향후 이 값을 받을 준비를 하되, 현재 개발/검증은 로컬 비추적 env/Dart define에 설정한 staging 고정 키 fallback으로 진행한다.
## 2. 확인 결과
실제 테스트 결과:
- `GET /api/v1/public/orgchart?token={ID}`: `401 Unauthorized`
- `GET /api/v1/public/orgchart?token={SECRET}`: `401 Unauthorized`
- 응답 body:
```json
{
"error": "invalid or expired share link",
"code": "invalid_session"
}
```
- `GET /api/v1/integrations/org-context``X-Baron-Key-ID`, `X-Baron-Key-Secret` header 사용: `200 OK`
따라서 현재 `tdc114plus`는 공유 링크 token 방식이 아니라 API Key 방식의 `org-context`를 원본 조직/사용자 데이터 소스로 본다.
## 3. Swagger 문서 위치
Swagger UI:
```text
https://sadmin.hmac.kr/api/docs#/Integrations/get_api_v1_integrations_org_context
```
OpenAPI YAML:
```text
https://sadmin.hmac.kr/api/openapi.yaml
```
Swagger에서 직접 확인할 때는 우측 상단 `Authorize`에 아래 값을 입력한다.
- `X-Baron-Key-ID`
- `X-Baron-Key-Secret`
## 4. 호출 방식
기본 호출 예시:
```bash
curl "https://sadmin.hmac.kr/api/v1/integrations/org-context?tenantSlug=hanmac-family&includeUsers=true&includeUserIds=true" \
-H "X-Baron-Key-ID: {KEY_ID}" \
-H "X-Baron-Key-Secret: {KEY_SECRET}"
```
주요 query parameter:
| 이름 | 필수 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `tenantSlug` | N | `hanmac-family` | 조회할 subtree root tenant slug |
| `includeUsers` | N | `true` | `false`이면 사용자 목록 없이 조직만 반환 |
| `includeUserIds` | N | `false` | `true`이면 사용자 `id`, `phone` 포함 |
## 5. 응답 구조
응답 최상위 구조:
```json
{
"schemaVersion": "baron.org-context.v1",
"issuedAt": "2026-07-03T02:31:37Z",
"scope": {
"tenantId": "tenant-uuid",
"tenantSlug": "hanmac-family"
},
"tree": {
"id": "tenant-uuid",
"type": "COMPANY_GROUP",
"name": "한맥가족",
"slug": "hanmac-family",
"members": [],
"children": []
},
"tenants": [
{
"id": "tenant-uuid",
"type": "ORGANIZATION",
"name": "플랫폼팀",
"slug": "platform-team",
"parentId": "root-uuid",
"status": "active",
"memberCount": 3,
"members": []
}
]
}
```
사용자 필드 예시:
```json
{
"id": "user-uuid",
"email": "user@example.com",
"name": "홍길동",
"phone": "+821012345678",
"department": "플랫폼팀",
"grade": "책임",
"position": "팀장",
"jobTitle": "개발",
"isOwner": false,
"isLeader": true,
"isPrimary": true
}
```
핵심 해석:
- `tree`: 실제 조직 트리 구조
- `tenants`: flatten된 조직 목록
- `members`: 각 조직에 직접 소속된 사용자 목록
- `memberCount`: 각 조직의 직접 소속 인원 수로 해석
- `totalMemberCount`: 응답에서 보장되는 값이 아니므로 앱에서 descendant를 포함해 계산
- `scope.tenantSlug`: 이번 조회의 기준 루트 slug
화면 표시 규칙:
- 하위조직 카드의 `n명``memberCount`가 아니라 앱이 계산한 `totalMemberCount`를 사용한다.
- `totalMemberCount`는 해당 조직 직접 소속 인원과 모든 하위조직 직접 소속 인원을 합산한다.
- 자식 조직이 있는 비-leaf 조직에서는 직원 목록보다 하위조직 목록을 우선 표시한다.
- 자식 조직이 없는 leaf 조직에 도달했을 때만 해당 leaf의 직접 소속 직원 목록을 표시한다.
- leaf 조직의 직접 소속 인원이 0명이면 `검색 결과 없음`이 정상일 수 있다.
## 6. 보안 및 저장 위치
이 API는 query parameter가 아니라 header 인증을 사용한다.
```http
X-Baron-Key-ID
X-Baron-Key-Secret
```
따라서 다음 원칙을 지킨다.
- tracked 모바일 Flutter 앱 소스와 tracked 문서에 실제 키를 넣지 않는다.
- 브라우저 주소창 query string으로 시크릿을 넣지 않는다.
- Baron SSO backend, 별도 서버, 또는 개발자 로컬 비추적 env/Dart define에만 저장한다.
- 실제 키 값은 저장소 tracked 파일에 커밋하지 않는다.
- 운영 배포 전에는 로그인 성공 후 받은 session credential 또는 안전한 서버 중계 구조로 전환한다.
현재 로컬 Baron SSO worktree에서는 아래 환경변수 이름으로 정리했다.
```env
TDC114PLUS_ORG_CONTEXT_BASE_URL=https://sadmin.hmac.kr
TDC114PLUS_ORG_CONTEXT_KEY_ID=
TDC114PLUS_ORG_CONTEXT_KEY_SECRET=
TDC114PLUS_ORG_CONTEXT_TENANT_SLUG=hanmac-family
TDC114PLUS_ORG_CONTEXT_INCLUDE_USERS=true
TDC114PLUS_ORG_CONTEXT_INCLUDE_USER_IDS=true
```
로컬 반영 위치:
- Baron SSO API worktree: `/home/ubuntu/workspace/baron-sso-tdc114plus-api/.env`
## 7. tdc114plus 반영 상태
2026-07-03 기준 반영 상태:
- 이 문서의 기준 API는 Baron 원본 `org-context`다.
- 앱 코드와 문서에 남아 있는 `tdc114plus` 전용 endpoint 가정은 레거시 흔적이며, 신규 정책의 공식 기준이 아니다.
현재 구현 동작:
- 외부 `org-context` 호출 성공 시: 외부 조직/사용자 응답을 앱 내부 DTO로 매핑
- 환경변수 미설정 시: 기존 로컬 fallback 동작이 남아 있을 수 있으므로 단계적 제거 대상이다
현재 매핑 결과:
- `tenants``org-context`의 tenant 목록 기준
- `employees`는 각 tenant `members` 기준
- 중복 사용자는 `id`, `email`, `phone`, `name` 순으로 dedupe
- `cache.source`는 외부 연동일 때 `org-context`
## 8. 브라우저 확인 방법
브라우저 주소창만으로는 header를 넣을 수 없으므로 직접 호출은 불가능하다.
확인 방법:
1. Swagger UI에서 `Authorize` 사용
2. 브라우저 개발자도구 console에서 `fetch` 사용
3. 터미널에서 `curl` 사용
예시 `fetch`:
```js
fetch("https://sadmin.hmac.kr/api/v1/integrations/org-context?tenantSlug=hanmac-family&includeUsers=true&includeUserIds=true", {
headers: {
"X-Baron-Key-ID": "YOUR_KEY_ID",
"X-Baron-Key-Secret": "YOUR_KEY_SECRET"
}
}).then(r => r.json()).then(console.log)
```
## 10. 운영 전환 메모
- 2026-07-10부터 팀장 지시에 따라 신규 앱 개발 시 Baron 원본 참고 API는 production host가 아니라 staging host `https://sadmin.hmac.kr/` 기준으로 다시 본다.
- 운영 키(`CLIENT ID`, `X-Baron-Key-Secret`)는 회전될 수 있으므로 tracked 문서에 박아두지 않고 로컬 비추적 `.env`에만 보관한다.
## 9. 후속 작업 메모
- 앱 내부 직원검색 모델
- 앱 내부 직원상세 모델
현재 이 두 경로는 기존 로컬 DB 기반이며, 조직도와 동일한 외부 `org-context` 소스로 완전히 통일할지는 후속 판단이 필요하다.
@@ -0,0 +1,153 @@
# Baron SSO org-context identity mirror 복구 요청 문서
작성일: 2026-07-03
목적: `tdc114plus` 앱의 직원검색/조직도 API가 외부 Baron SSO `org-context` 사용자 데이터를 정상적으로 조회할 수 있도록, Baron SSO 담당 개발자에게 현재 장애 상태와 필요한 조치 사항을 명확히 전달하기 위한 요청 문서다.
## 1. 요청 요약
현재 `tdc114plus` 연동 경로에서 Baron SSO 외부 `org-context` API의 조직 트리 조회는 가능하지만, 사용자 포함 조회가 아래 오류로 실패하고 있다.
```json
{"error":"identity mirror is not ready","code":"internal_error"}
```
따라서 Baron SSO 원본 서비스 `sadmin.hmac.kr` 측에서 `org-context` 사용자 조회에 필요한 identity mirror를 `ready` 상태로 복구하거나 재구성해 주는 조치가 필요하다.
## 2. 요청 목적
이번 요청의 목적은 아래와 같다.
- `tdc114plus`가 Baron backend를 통해 한맥가족 전체 직원 데이터를 정상 조회할 수 있도록 함
- 앱의 직원검색/조직도 데이터 소스를 `org-context` 기준으로 안정화
즉 외부 `org-context` 원본 사용자 데이터를 기준으로 앱 내부 데이터 구성이 가능하도록 복구함
## 3. 현재 확인된 사실
### 3-1. 인증 정보 자체는 정상
- 팀에서 전달받은 값은 `public/orgchart?token=...`용 token이 아니라 `GET /api/v1/integrations/org-context`용 API Key이다.
- 로컬 Baron SSO API worktree `.env`에는 아래 값이 반영되어 있다.
- `TDC114PLUS_ORG_CONTEXT_BASE_URL`
- `TDC114PLUS_ORG_CONTEXT_KEY_ID`
- `TDC114PLUS_ORG_CONTEXT_KEY_SECRET`
- `TDC114PLUS_ORG_CONTEXT_TENANT_SLUG=hanmac-family`
- `TDC114PLUS_ORG_CONTEXT_INCLUDE_USERS=true`
- `TDC114PLUS_ORG_CONTEXT_INCLUDE_USER_IDS=true`
### 3-2. 로컬 Baron backend 최신 코드/환경 반영은 확인
- `tdc114plus` 연동 handler는 외부 `org-context`를 우선 사용하도록 이미 반영했다.
- `baron_backend` 컨테이너 rebuild/recreate 후 실행 중 env에도 `TDC114PLUS_ORG_CONTEXT_*` 값이 주입된 것을 확인했다.
### 3-3. 현재 실제 장애 양상
외부 API를 직접 확인한 결과는 아래와 같다.
1. `includeUsers=false`
- 요청:
- `GET /api/v1/integrations/org-context?tenantSlug=hanmac-family&includeUsers=false&includeUserIds=false`
- 결과:
- `200 OK`
- 해석:
- 조직 트리/tenant 구조 자체는 조회 가능
2. `includeUsers=true`
- 요청:
- `GET /api/v1/integrations/org-context?tenantSlug=hanmac-family&includeUsers=true&includeUserIds=true`
- 결과:
- `500 Internal Server Error`
- 응답:
```json
{"error":"identity mirror is not ready","code":"internal_error"}
```
- 해석:
- 사용자 포함 `org-context` 조회 시 Baron SSO 원본 서비스 쪽 identity mirror가 준비되지 않아 실패
### 3-4. tdc114plus에서 보이는 영향
- `baron_backend` 최신 코드 반영 후 앱 연동 직원조회 경로는 `503 dependency_unavailable`로 실패
- backend 로그:
```text
[Tdc114Plus] external org-context employee load failed error="org-context status 500"
```
- 즉, 현재 `tdc114plus`는 외부 사용자 org-context를 쓰려 하지만 원본 서비스가 `500`을 반환해 앱에서 전체 직원 정보를 검증할 수 없는 상태다.
## 4. Baron SSO 담당 개발자에게 요청할 내용
아래 내용을 요청한다.
1. `sadmin.hmac.kr``GET /api/v1/integrations/org-context`에서 `includeUsers=true` 요청이 정상적으로 동작하도록 복구해 달라.
2. 현재 `identity mirror is not ready`가 발생하는 원인을 확인해 달라.
3. 필요한 경우 Kratos 기준 identity mirror warmup/full rebuild/recovery/reconciliation 작업을 수행해 달라.
4. 복구 후 아래 조건으로 재검증해 달라.
- `tenantSlug=hanmac-family`
- `includeUsers=true`
- `includeUserIds=true`
5. 복구 완료 후 응답이 `200 OK`로 내려오고, `tenants[].members`에 실제 사용자 데이터가 포함되는지 확인해 달라.
## 5. Baron SSO 쪽에서 필요한 처리 방향
현재 코드/운영 정책 문서 기준으로 예상되는 처리 방향은 아래와 같다.
1. `sadmin.hmac.kr` 원본 backend 로그에서 identity mirror warmup 실패 여부 확인
2. Redis identity mirror 상태 확인
- `identity:mirror:state`
3. Kratos Admin 연결/조회 가능 상태 확인
4. identity mirror warmup 또는 full rebuild 수행
5. 필요 시 repair / reconciliation / drift report 수행
6. 복구 후 `includeUsers=true` org-context 재검증
코드상 참고 포인트:
- 서버 시작 시 identity mirror warmup 수행 경로:
- `backend/cmd/server/main.go`
- identity mirror rebuild 경로:
- `backend/internal/handler/user_handler.go`
- org-context member export가 identity mirror 상태를 사용한다는 점:
- `backend/internal/handler/tenant_handler.go`
## 6. 이 요청이 처리되면 바로 이어서 가능한 작업
Baron SSO 쪽 복구가 완료되면 `tdc114plus` 쪽에서는 아래 작업을 바로 이어서 진행할 수 있다.
1. 앱 연동 조직도 데이터가 `cache.source=org-context`로 전환됐는지 확인
2. 앱 연동 직원검색 데이터가 한맥가족 전체 직원 데이터를 반환하는지 검증
3. 앱 연동 직원상세 데이터가 외부 사용자 id 기준으로 정상 조회되는지 검증
4. 중복 사용자, 다중 소속, tenant 없는 사용자 처리 정책 재점검
5. Flutter 앱에서 직원검색/조직도 실데이터 진입 검증
## 7. 요청 결과가 성공일 때의 완료 기준
아래 조건이 만족되면 이번 요청이 해결된 것으로 본다.
- `GET /api/v1/integrations/org-context?tenantSlug=hanmac-family&includeUsers=true&includeUserIds=true`
- `200 OK`
- 응답 body에 `tenants[].members[]` 실제 사용자 데이터가 포함됨
- `tdc114plus` 앱 연동 데이터가 `org-context` 기준으로 정상 구성됨
## 8. 전달용 짧은 요청 문안
아래 문안을 Baron SSO 담당 개발자에게 전달하면 된다.
```text
tdc114plus 연동 중 Baron org-context 사용자 포함 조회가 실패하고 있습니다.
- tenantSlug=hanmac-family
- includeUsers=false: 200 OK
- includeUsers=true: 500 {"error":"identity mirror is not ready","code":"internal_error"}
로컬 Baron backend 최신 코드와 org-context env 반영까지는 확인했습니다.
현재는 sadmin.hmac.kr 원본 org-context의 identity mirror가 ready가 아니어서
tdc114plus의 직원검색/조직도 사용자 조회가 막혀 있습니다.
org-context 사용자 조회에 필요한 identity mirror warmup / rebuild / recovery를 확인해 주시고,
복구 후 includeUsers=true 요청이 200으로 내려오도록 조치 부탁드립니다.
```
@@ -0,0 +1,411 @@
# 신규앱과 Baron SSO 연동 쉬운 설명
작성일: 2026-07-08
상태: 초안 v1
목적: 신규앱과 Baron SSO의 관계, 데이터 연동 방식, Hosted Login + PKCE 로그인, 모바일 배포 형태를 다른 팀원도 쉽게 이해할 수 있게 설명한다.
관련 근거:
- `docs/00_contract_tdc114plus_api_2026-07-02.md`
- `docs/00_policy_tdc114plus_screen_feature_2026-07-07.md`
- `docs/scenario_staging_baron_sso_login_verification_2026-07-06.md`
- `docs/policy_android_app_install_execution_2026-07-06.md`
- `app/lib/src/features/auth/data/auth_api_client.dart`
- `app/lib/src/features/auth/domain/auth_models.dart`
- `app/lib/src/features/auth/data/auth_repository.dart`
## 1. 한 줄 요약
`tdc114plus` 신규앱은 Baron SSO 자체가 아니라, Baron SSO를 로그인/사용자정보/조직정보 제공자로 사용하는 별도의 모바일 앱이다.
쉽게 말하면:
- 신규앱 = 사용자가 설치해서 실행하는 앱
- Baron SSO = 로그인과 사용자/조직 데이터를 제공하는 서버
- 두 시스템은 API로 데이터를 주고받는다
## 2. 신규앱과 Baron SSO의 관계
### 2.1 무엇이 누구인가
- 신규앱은 Flutter로 만든 Android/iOS 공통 모바일 앱이다.
- Baron SSO는 사용자 인증과 조직/직원 데이터를 관리하는 서버 쪽 시스템이다.
- 신규앱은 Baron SSO에 등록되는 별도 `RP(Relying Party)`로 본다.
여기서 `RP`는 쉽게 말하면:
- "SSO를 믿고 로그인 기능을 맡기는 서비스"
- 즉, 로그인 자체를 새로 만들지 않고 Baron SSO를 통해 인증받는 앱
그래서 신규앱은 Baron SSO의 일부 화면이 아니라, Baron SSO를 사용하는 별도 서비스라고 이해하면 된다.
## 3. 설치되는 앱 실행파일은 무엇인가
### 3.1 Android
Android에서 실제 기기에 설치되는 파일은 보통 아래 둘 중 하나다.
- `APK`
- `AAB`에서 스토어가 기기별로 풀어 배포한 설치 패키지
개발/테스트 단계에서는 보통:
- `flutter run`
- debug `APK`
를 사용한다.
운영 배포 관점에서는 보통:
- 사내 직접 배포면 `APK`
- Play Store 배포면 `AAB`
형태를 생각하면 된다.
현재 저장소 문맥상 Android 테스트에서는 debug APK를 직접 깔 수도 있지만, 기능 검증은 `flutter run` 중심으로 하도록 정책이 잡혀 있다. 이유는 이 앱이 실행 시점에 `TDC114_API_BASE` 같은 환경값을 함께 받아야 하기 때문이다.
### 3.2 iOS
iOS에서 실제 설치/배포에 쓰이는 것은 보통:
- 개발기기 설치용 app bundle
- TestFlight/App Store 배포용 `IPA`
라고 보면 된다.
즉 정리하면:
- Android는 주로 `APK` 또는 스토어용 `AAB`
- iOS는 주로 `IPA` 또는 TestFlight/App Store 배포
다.
## 4. 앱 실행파일 자체가 RP인가
이 질문은 자주 헷갈릴 수 있는데, 가장 쉬운 답은 아래와 같다.
- "앱 실행파일 그 자체"를 좁게 보면 그냥 설치 패키지다.
- 하지만 "그 앱 서비스 전체"를 넓게 보면 Baron SSO 입장에서는 하나의 `RP`다.
즉:
- `APK`, `IPA`는 설치물이다.
- 그 설치물을 통해 동작하는 `tdc114plus` 앱 서비스가 Baron SSO에 연동되는 `RP`다.
따라서 팀 내 설명에서는 아래처럼 말하면 가장 덜 헷갈린다.
`tdc114plus` 모바일 앱은 Baron SSO에 등록된 별도 RP이며, 사용자는 Android/iOS 기기에 그 RP의 클라이언트 앱을 설치해 사용한다.
## 5. 앱이 설치된 뒤 실제로 어떻게 동작하는가
네, 큰 흐름은 아래 이해가 맞다.
1. 사용자가 각 모바일 디바이스에 신규앱을 설치한다.
2. 사용자가 앱을 실행한다.
3. 앱은 Baron SSO authorization endpoint를 브라우저/커스텀탭으로 연다.
4. Baron SSO Hosted Login 화면에서 휴대폰번호 입력과 문자/메일 링크 인증을 처리한다.
5. 인증 완료 후 Baron SSO가 `https://114.hmac.kr/auth/callback`으로 authorization code를 돌려준다.
6. 앱은 PKCE `code_verifier`로 token을 교환하고 세션을 저장한다.
7. 앱은 그 세션으로 직원검색, 조직도, 사용자 표시 정보를 다시 조회한다.
8. 앱 화면은 Baron SSO가 준 로그인 결과와 조직/직원 데이터를 사용자에게 보여준다.
즉, "설치된 실행파일이 Baron SSO와 데이터를 주고받으면서 로그인과 표시 내용을 가져온다"는 이해는 맞다.
다만 더 정확히 말하면:
- 로그인 결과 일부는 앱 내부에 저장된다
- 이후 필요한 화면 데이터는 Baron SSO 연동 API를 다시 호출해서 가져온다
## 6. 어떤 데이터들을 주고받는가
## 6.1 로그인 시작 시 앱 -> Baron SSO
신규앱은 로그인 시작 시 headless API를 직접 호출하지 않는다.
앱은 PKCE 값을 만든 뒤 Baron SSO Hosted Login 화면을 연다.
```http
GET https://sso.hmac.kr/oidc/oauth2/auth
```
주요 query:
```text
client_id=tdc114plus-rp-client-id
redirect_uri=https://114.hmac.kr/auth/callback
response_type=code
scope=openid profile email tenants
state=random-state
nonce=random-nonce
code_challenge=S256-code-challenge
code_challenge_method=S256
```
의미:
- `client_id`: Baron SSO에 등록된 TDC114PLUS RP 식별자
- `redirect_uri`: 인증 후 앱으로 돌아오기 위한 App Link 주소
- `state`: callback 위조를 막기 위한 임시 검증값
- `code_challenge`: 앱이 가진 `code_verifier`를 해시한 PKCE 값
즉 앱은 "로그인 화면을 직접 만들지 않고", Baron SSO가 제공하는 로그인 화면으로 사용자를 보낸다.
## 6.2 Baron SSO Hosted Login 화면
휴대폰번호 입력, 문자/메일 링크 발송, 링크 승인 여부 확인은 Baron SSO 화면과 서버가 처리한다.
앱은 아래 정보를 직접 다루지 않는다.
- 휴대폰번호 인증 UI
- 승인 링크 생성
- `client_assertion`
- RP 개인키 또는 client secret
- headless `pendingRef` polling
이렇게 해야 공개 모바일 앱에 비밀키를 넣지 않는 PKCE 보안 모델과 맞다.
## 6.3 인증 완료 callback
인증이 끝나면 Baron SSO는 등록된 redirect URI로 이동한다.
```http
GET https://114.hmac.kr/auth/callback?code={authorization-code}&state={state}
```
앱은 Android App Link 또는 iOS Universal Link 설정을 통해 이 URL을 받아야 한다.
앱에서 확인할 것:
- callback의 `state`가 앱이 저장한 값과 같은지
- `code`가 존재하는지
- 오류 파라미터가 있으면 token 교환을 중단할지
## 6.4 token 교환
앱은 callback으로 받은 authorization code를 token endpoint에 보낸다.
```http
POST https://sso.hmac.kr/oidc/oauth2/token
```
요청 핵심:
```text
grant_type=authorization_code
client_id=tdc114plus-rp-client-id
code={authorization-code}
code_verifier={stored-code-verifier}
redirect_uri=https://114.hmac.kr/auth/callback
```
중요:
- PKCE 공개 앱이므로 client secret을 보내지 않는다.
- 앱이 처음 만든 `code_verifier`가 있어야 token 교환이 성공한다.
- token 교환 성공 후 앱은 access token, 만료시간, 사용자 기본 정보를 저장한다.
## 6.5 로그인 완료 후 앱 세션
token 교환이 성공하면 앱은 아래 정보를 세션으로 보관한다.
- access token
- 만료시간
- id token 또는 userinfo에서 얻은 사용자 기본 정보
이 사용자 정보는 아래처럼 쓰인다.
- `name`: 화면에 보여줄 사용자명
- `tenantName`, `tenantSlug`: 어느 회사/테넌트 소속인지
- `department`: 본인 부서
- `grade`, `position`, `jobTitle`: 직급/직위/직무
## 6.6 로그인 후 조회하는 업무 데이터
로그인 후에는 아래 같은 업무 데이터를 Baron SSO 연동 API에서 조회한다.
- 직원 목록
- 조직/가족사 목록
- 조직도
- 직원 상세 정보
현재 기준 원본 조회 API 메모:
- `GET /api/v1/integrations/org-context`
- `GET /api/v1/public/orgchart`
레거시 `/api/v1/tdc114plus/...` 데이터 경로는 과거 흔적으로만 보고 단계적으로 제거한다.
문서 기준 주요 데이터 API는 아래와 같다.
- `GET /api/v1/integrations/org-context`
- `GET /api/v1/public/orgchart`
즉 Baron SSO는 로그인만 해주는 것이 아니라, 앱이 필요한 직원/조직 데이터도 함께 제공하는 역할을 한다.
## 7. 데이터 형식은 무엇인가
기본 형식은 HTTP + JSON 이다.
조금 더 풀면:
- 통신 방식: 모바일 앱이 HTTPS API 호출
- 요청 본문 형식: JSON
- 응답 본문 형식: JSON
- 헤더: 보통 `Content-Type: application/json`, `Accept: application/json`
즉 이 앱은 웹페이지를 긁어오는 방식이 아니라, 구조화된 JSON API를 호출해서 데이터를 받는다.
## 8. 세션은 앱 안에 어떻게 저장되는가
코드상 현재 앱은 로그인 성공 시 아래 정보를 저장한다.
- `token`
- `expiresAt`
- `user`
저장 위치는 앱 내부 저장소(`SharedPreferences`)다.
그래서 앱을 다시 켰을 때:
- 저장된 세션이 아직 유효하면 로그인 화면을 건너뛴다
- 세션이 없거나 만료되면 다시 Baron SSO 로그인으로 보낸다
즉 앱은 매 화면마다 처음부터 새 로그인하는 것이 아니라, 받은 세션을 보관했다가 재사용한다.
## 9. 헤드리스 로그인(headless login)은 무엇이고, 왜 기본 방식이 아닌가
가장 쉬운 설명부터 하면:
헤드리스 로그인은 "앱 안에 Baron SSO의 로그인 웹화면을 직접 띄우지 않고, 앱이 API로 로그인 절차를 진행하는 방식"이다.
`headless`를 직역하면 "머리/화면이 없는" 느낌인데, 여기서는:
- 로그인용 별도 웹페이지 중심이 아니라
- 백그라운드 API 호출 중심으로 로그인 절차를 진행한다
는 뜻으로 이해하면 된다.
다만 현재 TDC114PLUS 기본 로그인 방식은 headless가 아니다.
현재 기준은 `Baron SSO Hosted Login + OIDC Authorization Code + PKCE`다.
이유는 간단하다.
- Flutter 모바일 앱은 공개 클라이언트라서 비밀키를 안전하게 숨길 수 없다.
- Baron Swagger의 headless API는 `client_assertion` 같은 confidential client 성격의 값을 요구한다.
- 앱이 headless API를 직접 호출하려면 APK 안에 비밀값 또는 개인키를 넣는 위험한 구조가 될 수 있다.
- Hosted Login + PKCE는 모바일 앱 표준 보안 모델과 더 잘 맞는다.
## 10. 이번 신규앱 로그인은 왜 특별한가
이번 신규앱 기본 로그인은 단순한 "전화번호 넣고 바로 로그인"이 아니다.
흐름은 아래와 같다.
1. 앱이 Baron SSO Hosted Login 화면을 브라우저/커스텀탭으로 연다.
2. 사용자가 Baron SSO 화면에서 휴대폰번호를 입력한다.
3. Baron SSO가 문자 또는 메일로 승인 링크를 발송한다.
4. 사용자가 그 링크를 열어 승인한다.
5. Baron SSO가 `https://114.hmac.kr/auth/callback`으로 authorization code를 돌려준다.
6. 앱이 PKCE 방식으로 token을 교환한다.
7. 앱이 세션 저장 후 첫 화면으로 진입한다.
즉 핵심은:
- 앱은 로그인 화면을 직접 구현하지 않는다.
- 휴대폰번호 입력과 링크 승인은 Baron SSO가 맡는다.
- 앱은 callback code를 받아 PKCE token 교환만 수행한다.
이다.
## 11. 왜 굳이 Hosted Login + PKCE로 하나
장점은 아래처럼 설명할 수 있다.
- 앱이 비밀번호, 휴대폰번호 인증 로직, 승인 링크를 직접 만지지 않는다.
- Baron SSO가 인증 UI와 인증 절차를 책임진다.
- 앱에는 client secret이나 개인키를 넣지 않아도 된다.
- Android/iOS 모두 표준 OIDC + PKCE 방식으로 확장하기 쉽다.
- 로그인 정책이 바뀌어도 Baron SSO Hosted Login 쪽을 중심으로 바꾸면 된다.
하지만 주의할 점도 있다.
- 앱 코드만 있다고 로그인 검증이 끝나지 않는다.
- Baron SSO RP 등록, redirect URI, App Link, PKCE token endpoint가 모두 맞아야 한다.
- staging/운영 환경에서 실제 테스트 번호와 링크 수신 경로가 맞아야 end-to-end 검증이 된다.
## 12. Hosted Login + PKCE와 headless 방식 차이
Hosted Login + PKCE:
1. 로그인 페이지로 이동
2. Baron SSO 화면에서 인증수단 입력
3. 문자/메일 링크 승인
4. App Link callback으로 앱 복귀
5. PKCE token 교환
6. 로그인 완료
headless 직접 호출:
1. 앱 화면에서 전화번호 입력
2. 앱이 API 호출
3. 사용자는 외부로 받은 링크를 열어 승인
4. 앱은 API로 상태를 확인
5. 완료되면 앱이 세션을 저장
현재 앱 기본 정책은 첫 번째, 즉 Hosted Login + PKCE다.
headless 직접 호출은 별도 신뢰 백엔드가 생기거나 Baron SSO가 모바일 공개 RP용 계약을 제공할 때만 재검토한다.
## 13. "전화번호만 넣으면 바로 로그인"과 같은가
아니다. 이번 기본 흐름은 그것과 다르다.
이번 기본 흐름은:
- Baron SSO 로그인 화면 진입
- 전화번호 입력
- 링크 발송
- 사용자 승인
- App Link callback
- PKCE token 교환 후 세션 발급
이다.
즉 전화번호 입력만으로 즉시 로그인시키는 구조가 아니라, 승인 단계를 끼운 비동기 로그인 구조다.
## 14. 팀원들이 가장 헷갈리지 않게 설명하는 표현
아래 표현을 권장한다.
`tdc114plus`는 Baron SSO에 등록된 별도 모바일 RP 앱이다. 사용자는 Android나 iPhone에 앱을 설치하고, 앱은 Baron SSO Hosted Login 화면을 브라우저/커스텀탭으로 열어 로그인한다. 전화번호 입력과 문자/메일 링크 승인은 Baron SSO 화면과 서버가 처리하며, 인증 완료 뒤 `https://114.hmac.kr/auth/callback` App Link로 앱에 돌아온다. 앱은 callback의 authorization code를 PKCE 방식으로 token 교환한 뒤 직원검색/조직도 같은 데이터를 Baron SSO 연동 API에서 조회해 화면에 표시한다.
## 15. 자주 나오는 질문에 대한 짧은 답
### Q1. 신규앱은 Baron SSO 안에 들어가는가
아니라기보다, Baron SSO를 사용하는 별도 앱이다.
### Q2. 앱 실행파일 자체가 RP인가
설치파일 자체보다는, 그 설치파일로 동작하는 앱 서비스 전체를 RP라고 보는 것이 더 정확하다.
### Q3. Android는 APK로 배포하는가
개발/사내 배포는 APK가 가능하고, 스토어 배포는 보통 AAB를 사용한다.
### Q4. iOS는 APK처럼 설치하는가
아니다. 보통 IPA 또는 TestFlight/App Store 방식이다.
### Q5. 앱이 설치된 뒤 Baron SSO와 계속 통신하는가
그렇다. 로그인할 때도 통신하고, 로그인 후 직원/조직 데이터를 가져올 때도 통신한다.
### Q6. 헤드리스 로그인은 화면이 아예 없다는 뜻인가
아니다. 사용자 화면은 있을 수 있다. 다만 현재 TDC114PLUS 기본 방식은 headless가 아니라 Baron SSO Hosted Login 화면을 브라우저/커스텀탭으로 여는 방식이다.
## 16. 결론
이번 신규앱은 단말에 설치되는 일반 모바일 앱이지만, 인증과 주요 사용자/조직 데이터는 Baron SSO와의 API 연동에 의존한다. 따라서 이 앱은 "독립 실행은 되지만 독립 인증은 하지 않는 RP 앱"이라고 이해하면 가장 정확하다.
또한 이번 로그인은 단순 즉시 로그인 방식이 아니라 `Hosted Login -> 문자/메일 링크 인증 -> App Link callback -> PKCE token 교환 -> 세션 저장` 흐름이다. 그래서 앱 실행파일만 설치되었다고 끝나는 것이 아니라, Baron SSO RP 설정, callback 도메인, App Link, token 교환이 함께 정상 동작해야 비로소 로그인과 첫 화면 진입이 완성된다.
@@ -0,0 +1,571 @@
# 신규어플 부서 대시보드 입력용 프로젝트/업무계획 정리
작성일: 2026-07-14
용도: 부서 대시보드의 `기본정보`, `업무 계획`, `업무 관리`, `로드맵`, `변경사항` 입력용 초안
## 0. 캡처 화면 기준 빠른 입력 매칭표
캡처에 보이는 입력 항목명을 기준으로 바로 매칭했다.
### 0.1 기본정보 화면
| 캡처상 입력 항목명 | 넣을 내용 |
| --- | --- |
| 프로젝트명 | `TDC114 신규앱 구축` |
| 프로젝트 시작일 | `2026-07-02` |
| 프로젝트 종료일 | `2026-08-28` |
| 분야 | `시스템화` |
| 관리상태 | `정상` |
| 수행팀 | `IS 3팀` |
| PM / 대표 | `-` 또는 실제 PM명 |
| 업무수행자 | `문형석 외 2명` |
| 협업팀 | `기술개발센터, Baron SSO 연계부서` |
| 프로젝트 설명 | `Baron SSO 연계 기반의 TDC114 신규 모바일 앱을 구축하여 직원검색, 전화번호검색, 조직도, 즐겨찾기 등 핵심 기능을 통합 제공하고 Android/iOS 공통 운영 기반과 iOS 대응 구조를 마련한다.` |
| 기대 성과 | `사내 직원검색 및 조직도 조회 업무를 모바일에서 일원화하고, Android/iOS 공통 신규앱 기반의 인증 및 운영 체계를 확보하여 사용자 접근성과 유지보수 효율을 높인다.` |
| 상시업무 | `미체크 권장` |
| 핵심 추진 프로젝트 | `체크 권장` |
### 0.2 업무 계획 화면
#### 목표 1
| 캡처상 입력 항목명 | 넣을 내용 |
| --- | --- |
| 목표명 | `신규앱 인증/접속 기반 구축` |
| 업무성격 | `자료조사` 또는 `기능구현` |
| 업무수행자 | `문형석` |
| 계획기간 | `2026-07-02 ~ 2026-07-17` |
| 수행기간 | `2026-07-02 ~ 2026-07-17` |
| 예상 M/H | `24.0h` |
| 실제 M/H | `진행 후 입력` |
##### 목표 1의 업무 항목
| 업무명 | 업무성격 | 업무수행자 | 계획기간 | 수행기간 | 비고 |
| --- | --- | --- | --- | --- | --- |
| Baron SSO 로그인 연계 설계 | 기능구현 | 문형석 | 2026-07-02 ~ 2026-07-08 | 2026-07-02 ~ 2026-07-08 | Hosted Login, PKCE, Callback 반영 |
| 앱 환경설정 및 실행 구조 정비 | 기능구현 | 문형석 | 2026-07-06 ~ 2026-07-13 | 2026-07-06 ~ 2026-07-13 | 환경값, 빌드 설정, 실행 경로 정리 |
| 로그인 기본 동작 검증 | 내용정리 | 문형석 | 2026-07-14 ~ 2026-07-17 | 2026-07-14 ~ 2026-07-17 | 실기기/테스트 환경 기준 검증 |
##### 목표 1의 세부업무 항목
| 상위 업무명 | 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- | --- |
| Baron SSO 로그인 연계 설계 | OIDC/PKCE 인증 흐름 정의 | 2026-07-02 ~ 2026-07-04 | 2026-07-02 ~ 2026-07-04 | 인증 URL, state, code verifier 흐름 정리 |
| Baron SSO 로그인 연계 설계 | Callback 처리 정책 정리 | 2026-07-05 ~ 2026-07-08 | 2026-07-05 ~ 2026-07-08 | callback URL, 예외 응답, 세션 연결 기준 정리 |
| 앱 환경설정 및 실행 구조 정비 | 환경변수 및 빌드 설정 정리 | 2026-07-06 ~ 2026-07-09 | 2026-07-06 ~ 2026-07-09 | 실행 환경값, 빌드 설정, 앱 실행 조건 정비 |
| 앱 환경설정 및 실행 구조 정비 | 개발/테스트 실행 경로 점검 | 2026-07-10 ~ 2026-07-13 | 2026-07-10 ~ 2026-07-13 | 실행 경로, 테스트 기준, 로그 확인 절차 정리 |
| 로그인 기본 동작 검증 | 로그인 시나리오 점검 | 2026-07-14 ~ 2026-07-15 | 2026-07-14 ~ 2026-07-15 | 정상 로그인 및 기본 이동 흐름 점검 |
| 로그인 기본 동작 검증 | 예외 케이스 확인 | 2026-07-16 ~ 2026-07-17 | 2026-07-16 ~ 2026-07-17 | 실패, 취소, 재시도 케이스 확인 |
#### 목표 2
| 캡처상 입력 항목명 | 넣을 내용 |
| --- | --- |
| 목표명 | `직원검색/조직도 핵심 기능 구현` |
| 업무성격 | `기능구현` |
| 업무수행자 | `문형석 외 1명` |
| 계획기간 | `2026-07-20 ~ 2026-08-14` |
| 수행기간 | `2026-07-20 ~ 2026-08-14` |
| 예상 M/H | `48.0h` |
| 실제 M/H | `진행 후 입력` |
##### 목표 2의 업무 항목
| 업무명 | 업무성격 | 업무수행자 | 계획기간 | 수행기간 | 비고 |
| --- | --- | --- | --- | --- | --- |
| 직원검색 및 전화번호검색 기능 구현 | 기능구현 | 문형석 | 2026-07-20 ~ 2026-07-29 | 2026-07-20 ~ 2026-07-29 | 검색 조건, 결과 리스트, 상세 연결 |
| 가족사 필터 및 조직도 조회 구현 | 기능구현 | 문형석 외 1명 | 2026-07-27 ~ 2026-08-05 | 2026-07-27 ~ 2026-08-05 | 조직 탐색, 부서 목록, 정렬 규칙 반영 |
| 직원 상세/전화/문자 연계 구성 | 기능구현 | 문형석 | 2026-08-03 ~ 2026-08-10 | 2026-08-03 ~ 2026-08-10 | 상세정보, 전화걸기, 문자보내기 연계 |
| 즐겨찾기 및 화면 UX 보정 | 기능구현 | 문형석 외 1명 | 2026-08-08 ~ 2026-08-14 | 2026-08-08 ~ 2026-08-14 | 로컬 저장, 화면 사용성 개선 |
| iOS 대응 구조 및 실행 검토 | 자료조사 | 문형석 | 2026-08-11 ~ 2026-08-14 | 2026-08-11 ~ 2026-08-14 | iOS 실행 조건, 링크 처리, 배포 준비 항목 점검 |
##### 목표 2의 세부업무 항목
| 상위 업무명 | 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- | --- |
| 직원검색 및 전화번호검색 기능 구현 | 검색 조건 및 입력 규칙 적용 | 2026-07-20 ~ 2026-07-23 | 2026-07-20 ~ 2026-07-23 | 이름/전화번호 검색 조건 및 입력 규칙 반영 |
| 직원검색 및 전화번호검색 기능 구현 | 검색 결과 리스트 구성 | 2026-07-24 ~ 2026-07-29 | 2026-07-24 ~ 2026-07-29 | 결과 리스트, 상세 진입, 빈 결과 처리 |
| 가족사 필터 및 조직도 조회 구현 | 가족사 필터 구성 | 2026-07-27 ~ 2026-07-30 | 2026-07-27 ~ 2026-07-30 | 회사/조직 필터 선택 구조 반영 |
| 가족사 필터 및 조직도 조회 구현 | 조직도 조회 및 정렬 규칙 반영 | 2026-07-31 ~ 2026-08-05 | 2026-07-31 ~ 2026-08-05 | 조직 탐색, 목록 정렬, 하위 이동 처리 |
| 직원 상세/전화/문자 연계 구성 | 직원 상세정보 화면 구성 | 2026-08-03 ~ 2026-08-06 | 2026-08-03 ~ 2026-08-06 | 부서/직위/연락처 등 상세 정보 구성 |
| 직원 상세/전화/문자 연계 구성 | 전화/문자 실행 연계 | 2026-08-07 ~ 2026-08-10 | 2026-08-07 ~ 2026-08-10 | 전화걸기, 문자보내기 외부 앱 연계 |
| 즐겨찾기 및 화면 UX 보정 | 즐겨찾기 저장 기능 반영 | 2026-08-08 ~ 2026-08-11 | 2026-08-08 ~ 2026-08-11 | 로컬 저장 및 목록 반영 |
| 즐겨찾기 및 화면 UX 보정 | 주요 화면 UX 보완 | 2026-08-12 ~ 2026-08-14 | 2026-08-12 ~ 2026-08-14 | 화면 동선, 선택 상태, 가독성 보정 |
| iOS 대응 구조 및 실행 검토 | iOS 로그인/링크 처리 검토 | 2026-08-11 ~ 2026-08-12 | 2026-08-11 ~ 2026-08-12 | iOS callback 처리, 링크 연결, 정책 차이 점검 |
| iOS 대응 구조 및 실행 검토 | iOS 배포 준비 항목 정리 | 2026-08-13 ~ 2026-08-14 | 2026-08-13 ~ 2026-08-14 | 서명, 배포 방식, 테스트 기준 정리 |
#### 목표 3
| 캡처상 입력 항목명 | 넣을 내용 |
| --- | --- |
| 목표명 | `안정화 및 운영 전환 준비` |
| 업무성격 | `자료조사` |
| 업무수행자 | `문형석 외 2명` |
| 계획기간 | `2026-08-17 ~ 2026-08-28` |
| 수행기간 | `2026-08-17 ~ 2026-08-28` |
| 예상 M/H | `36.0h` |
| 실제 M/H | `진행 후 입력` |
##### 목표 3의 업무 항목
| 업무명 | 업무성격 | 업무수행자 | 계획기간 | 수행기간 | 비고 |
| --- | --- | --- | --- | --- | --- |
| 통합 테스트 및 예외 시나리오 점검 | 기능구현 | 문형석 외 1명 | 2026-08-17 ~ 2026-08-21 | 2026-08-17 ~ 2026-08-21 | 로그인, 검색, 조직도, 상세 기능 검증 |
| 배포 환경 및 운영 체크리스트 정리 | 내용정리 | 문형석 | 2026-08-20 ~ 2026-08-25 | 2026-08-20 ~ 2026-08-25 | Android/iOS 배포 기준, 장애 대응, 운영 절차 정리 |
| 시범 운영 및 보완사항 반영 | 기능구현 | 문형석 외 2명 | 2026-08-24 ~ 2026-08-28 | 2026-08-24 ~ 2026-08-28 | 피드백 반영 후 1차 마무리 |
##### 목표 3의 세부업무 항목
| 상위 업무명 | 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- | --- |
| 통합 테스트 및 예외 시나리오 점검 | 핵심 기능 통합 테스트 | 2026-08-17 ~ 2026-08-19 | 2026-08-17 ~ 2026-08-19 | 로그인, 검색, 조직도, 상세 흐름 점검 |
| 통합 테스트 및 예외 시나리오 점검 | 예외 시나리오 및 오류 확인 | 2026-08-20 ~ 2026-08-21 | 2026-08-20 ~ 2026-08-21 | 실패 케이스, 오류 메시지, 재시도 확인 |
| 배포 환경 및 운영 체크리스트 정리 | Android/iOS 배포 기준 정리 | 2026-08-20 ~ 2026-08-22 | 2026-08-20 ~ 2026-08-22 | 배포 버전, 환경, 점검 항목 정리 |
| 배포 환경 및 운영 체크리스트 정리 | 운영 절차 및 장애 대응 정리 | 2026-08-23 ~ 2026-08-25 | 2026-08-23 ~ 2026-08-25 | 운영 절차, 문의 대응, 장애 체크 정리 |
| 시범 운영 및 보완사항 반영 | 사용자 피드백 반영 | 2026-08-24 ~ 2026-08-26 | 2026-08-24 ~ 2026-08-26 | 시범 운영 중 확인된 보완사항 반영 |
| 시범 운영 및 보완사항 반영 | 1차 완료 정리 | 2026-08-27 ~ 2026-08-28 | 2026-08-27 ~ 2026-08-28 | 완료 보고 및 운영 전환 정리 |
### 0.3 업무 관리 화면
| 캡처상 입력 항목명 | 넣을 내용 |
| --- | --- |
| ROADMAP 전체 업무 > 목표 1 | `신규앱 인증/접속 기반 구축` |
| ROADMAP 전체 업무 > 목표 2 | `직원검색/조직도 핵심 기능 구현` |
| ROADMAP 전체 업무 > 목표 3 | `안정화 및 운영 전환 준비` |
| CHECK 확인사항 1 | `Baron SSO 연계 정보 확정` |
| CHECK 확인사항 2 | `조직/직원 데이터 기준 정합성 확인` |
| CHECK 확인사항 3 | `Android/iOS 배포 방식 및 운영 기준 협의` |
| CHECK 확인사항 4 | `iOS 링크/배포 정책 확인` |
### 0.4 통합 대시보드 카드 화면
| 캡처상 표시 항목 | 넣을 내용 |
| --- | --- |
| 프로젝트명 | `TDC114 신규앱 구축` |
| 현재업무 | `Baron SSO 로그인 연계 및 직원검색/조직도 1차 구축` |
| 목표일 | `26.08.28 완료 목표` |
| 기간 표기 | `26.07 ~ 26.08` |
## 1. 작성 방향
- 기존 `바로 로그인v1.0`처럼 짧고 명확한 프로젝트명으로 정리한다.
- 현재 저장소와 문서 기준으로 신규어플은 `tdc114plus` 모바일 앱 구축/고도화 성격으로 정리한다.
- 날짜는 2026년 3분기 기준으로 자연스럽게 이어지도록 조정했다.
- 조직명, 참여자명은 실제 등록 가능한 선택값에 맞춰 마지막 입력 단계에서 미세 조정하면 된다.
## 2. 기본정보 입력 초안
### 2.1 권장 프로젝트명
`TDC114 신규앱 구축`
대안:
- `TDC114 신규앱 고도화`
- `TDC114 모바일 전화번호부 구축`
- `TDC114 신규앱 1차 구축`
가장 무난한 권장안은 `TDC114 신규앱 구축`이다.
### 2.2 기본정보 필드별 권장값
| 항목 | 입력 권장값 |
| --- | --- |
| 프로젝트명 | `TDC114 신규앱 구축` |
| 프로젝트 시작일 | `2026-07-02` |
| 프로젝트 종료일 | `2026-08-28` |
| 분야 | `시스템화` |
| 관리상태 | `정상` |
| 수행팀 | `IS 3팀` 또는 실제 담당팀 |
| PM / 대표 | `-` 또는 실제 PM |
| 업무수행자 | `문형석 외 2명` |
| 협업팀 | `기술개발센터, Baron SSO 연계부서` |
| 상시업무 여부 | 미체크 권장 |
| 핵심 추진 프로젝트 | 체크 권장 |
### 2.3 프로젝트 설명
아래 문안 중 하나를 그대로 입력하면 된다.
#### 설명안 A
`Baron SSO 연계 기반의 TDC114 신규 모바일 앱을 구축하여 직원검색, 전화번호검색, 조직도, 즐겨찾기 등 핵심 기능을 통합 제공하고 Android/iOS 공통 운영 기반과 iOS 대응 구조를 마련한다.`
#### 설명안 B
`기존 전화번호부 업무를 모바일 신규앱으로 전환하기 위한 프로젝트로, Baron SSO 로그인 연계와 조직/직원 정보 조회 기능을 중심으로 1차 서비스를 구축한다.`
### 2.4 기대 성과
아래 문안 중 하나를 그대로 입력하면 된다.
#### 기대성과안 A
`사내 직원검색 및 조직도 조회 업무를 모바일에서 일원화하고, Android/iOS 공통 신규앱 기반의 인증 및 운영 체계를 확보하여 사용자 접근성과 유지보수 효율을 높인다.`
#### 기대성과안 B
`기존 분산된 연락처 조회 업무를 통합하고, Baron SSO 연계 표준 로그인 체계를 Android/iOS 공통으로 적용하여 향후 기능 확장과 서비스 안정화 기반을 확보한다.`
## 3. 대시보드 카드 노출용 요약 문안
통합 화면 카드에 보일 수 있도록 짧게 정리한 문안이다.
### 3.1 프로젝트 표시명
`TDC114 신규앱 구축`
### 3.2 현재업무 표시 문안
`Baron SSO 로그인 연계 및 직원검색/조직도 1차 구축`
대안:
- `신규앱 기본 기능 개발 및 인증 연동`
- `모바일 전화번호부 앱 1차 기능 구현`
### 3.3 목표일 표시 기준
`26.08.28 완료 목표`
## 4. 업무 계획 입력 초안
업무 계획 화면에서 `목표 -> 업무 -> 세부업무` 구조로 입력하기 쉽게 정리했다.
### 4.1 목표 1
| 항목 | 내용 |
| --- | --- |
| 목표명 | `신규앱 인증/접속 기반 구축` |
| 업무성격 | `자료조사` 또는 `기능구현` |
| 업무수행자 | `문형석` |
| 계획기간 | `2026-07-02 ~ 2026-07-17` |
| 수행기간 | `2026-07-02 ~ 2026-07-17` |
| 예상 M/H | `24.0h` |
#### 업무 1.1
| 항목 | 내용 |
| --- | --- |
| 업무명 | `Baron SSO 로그인 연계 설계` |
| 업무성격 | `기능구현` |
| 업무수행자 | `문형석` |
| 계획기간 | `2026-07-02 ~ 2026-07-08` |
| 수행기간 | `2026-07-02 ~ 2026-07-08` |
| 비고 | `Hosted Login, PKCE, Callback 정책 반영` |
세부업무:
| 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- |
| `OIDC/PKCE 인증 흐름 정의` | `2026-07-02 ~ 2026-07-04` | `2026-07-02 ~ 2026-07-04` | `인증 URL, state, code verifier 흐름 정리` |
| `Callback 처리 정책 정리` | `2026-07-05 ~ 2026-07-08` | `2026-07-05 ~ 2026-07-08` | `callback URL, 예외 응답, 세션 연결 기준 정리` |
#### 업무 1.2
| 항목 | 내용 |
| --- | --- |
| 업무명 | `앱 환경설정 및 실행 구조 정비` |
| 업무성격 | `기능구현` |
| 업무수행자 | `문형석` |
| 계획기간 | `2026-07-06 ~ 2026-07-13` |
| 수행기간 | `2026-07-06 ~ 2026-07-13` |
| 비고 | `환경값, 빌드 설정, 실행 경로 정리` |
세부업무:
| 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- |
| `환경변수 및 빌드 설정 정리` | `2026-07-06 ~ 2026-07-09` | `2026-07-06 ~ 2026-07-09` | `실행 환경값, 빌드 설정, 앱 실행 조건 정비` |
| `개발/테스트 실행 경로 점검` | `2026-07-10 ~ 2026-07-13` | `2026-07-10 ~ 2026-07-13` | `실행 경로, 테스트 기준, 로그 확인 절차 정리` |
#### 업무 1.3
| 항목 | 내용 |
| --- | --- |
| 업무명 | `로그인 기본 동작 검증` |
| 업무성격 | `내용정리` |
| 업무수행자 | `문형석` |
| 계획기간 | `2026-07-14 ~ 2026-07-17` |
| 수행기간 | `2026-07-14 ~ 2026-07-17` |
| 비고 | `실기기/테스트 환경 기준 검증` |
세부업무:
| 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- |
| `로그인 시나리오 점검` | `2026-07-14 ~ 2026-07-15` | `2026-07-14 ~ 2026-07-15` | `정상 로그인 및 기본 이동 흐름 점검` |
| `예외 케이스 확인` | `2026-07-16 ~ 2026-07-17` | `2026-07-16 ~ 2026-07-17` | `실패, 취소, 재시도 케이스 확인` |
### 4.2 목표 2
| 항목 | 내용 |
| --- | --- |
| 목표명 | `직원검색/조직도 핵심 기능 구현` |
| 업무성격 | `기능구현` |
| 업무수행자 | `문형석 외 1명` |
| 계획기간 | `2026-07-20 ~ 2026-08-14` |
| 수행기간 | `2026-07-20 ~ 2026-08-14` |
| 예상 M/H | `48.0h` |
#### 업무 2.1
| 항목 | 내용 |
| --- | --- |
| 업무명 | `직원검색 및 전화번호검색 기능 구현` |
| 업무성격 | `기능구현` |
| 업무수행자 | `문형석` |
| 계획기간 | `2026-07-20 ~ 2026-07-29` |
| 수행기간 | `2026-07-20 ~ 2026-07-29` |
| 비고 | `검색 조건, 결과 리스트, 상세 연결` |
세부업무:
| 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- |
| `검색 조건 및 입력 규칙 적용` | `2026-07-20 ~ 2026-07-23` | `2026-07-20 ~ 2026-07-23` | `이름/전화번호 검색 조건 및 입력 규칙 반영` |
| `검색 결과 리스트 구성` | `2026-07-24 ~ 2026-07-29` | `2026-07-24 ~ 2026-07-29` | `결과 리스트, 상세 진입, 빈 결과 처리` |
#### 업무 2.2
| 항목 | 내용 |
| --- | --- |
| 업무명 | `가족사 필터 및 조직도 조회 구현` |
| 업무성격 | `기능구현` |
| 업무수행자 | `문형석 외 1명` |
| 계획기간 | `2026-07-27 ~ 2026-08-05` |
| 수행기간 | `2026-07-27 ~ 2026-08-05` |
| 비고 | `조직 탐색, 부서 목록, 정렬 규칙 반영` |
세부업무:
| 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- |
| `가족사 필터 구성` | `2026-07-27 ~ 2026-07-30` | `2026-07-27 ~ 2026-07-30` | `회사/조직 필터 선택 구조 반영` |
| `조직도 조회 및 정렬 규칙 반영` | `2026-07-31 ~ 2026-08-05` | `2026-07-31 ~ 2026-08-05` | `조직 탐색, 목록 정렬, 하위 이동 처리` |
#### 업무 2.3
| 항목 | 내용 |
| --- | --- |
| 업무명 | `직원 상세/전화/문자 연계 구성` |
| 업무성격 | `기능구현` |
| 업무수행자 | `문형석` |
| 계획기간 | `2026-08-03 ~ 2026-08-10` |
| 수행기간 | `2026-08-03 ~ 2026-08-10` |
| 비고 | `상세정보, 전화걸기, 문자보내기 연계` |
세부업무:
| 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- |
| `직원 상세정보 화면 구성` | `2026-08-03 ~ 2026-08-06` | `2026-08-03 ~ 2026-08-06` | `부서/직위/연락처 등 상세 정보 구성` |
| `전화/문자 실행 연계` | `2026-08-07 ~ 2026-08-10` | `2026-08-07 ~ 2026-08-10` | `전화걸기, 문자보내기 외부 앱 연계` |
#### 업무 2.5
| 항목 | 내용 |
| --- | --- |
| 업무명 | `iOS 대응 구조 및 실행 검토` |
| 업무성격 | `자료조사` |
| 업무수행자 | `문형석` |
| 계획기간 | `2026-08-11 ~ 2026-08-14` |
| 수행기간 | `2026-08-11 ~ 2026-08-14` |
| 비고 | `iOS 실행 조건, 링크 처리, 배포 준비 항목 점검` |
세부업무:
| 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- |
| `iOS 로그인/링크 처리 검토` | `2026-08-11 ~ 2026-08-12` | `2026-08-11 ~ 2026-08-12` | `iOS callback 처리, 링크 연결, 정책 차이 점검` |
| `iOS 배포 준비 항목 정리` | `2026-08-13 ~ 2026-08-14` | `2026-08-13 ~ 2026-08-14` | `서명, 배포 방식, 테스트 기준 정리` |
#### 업무 2.4
| 항목 | 내용 |
| --- | --- |
| 업무명 | `즐겨찾기 및 화면 UX 보정` |
| 업무성격 | `기능구현` |
| 업무수행자 | `문형석 외 1명` |
| 계획기간 | `2026-08-08 ~ 2026-08-14` |
| 수행기간 | `2026-08-08 ~ 2026-08-14` |
| 비고 | `로컬 저장, 화면 사용성 개선` |
세부업무:
| 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- |
| `즐겨찾기 저장 기능 반영` | `2026-08-08 ~ 2026-08-11` | `2026-08-08 ~ 2026-08-11` | `로컬 저장 및 목록 반영` |
| `주요 화면 UX 보완` | `2026-08-12 ~ 2026-08-14` | `2026-08-12 ~ 2026-08-14` | `화면 동선, 선택 상태, 가독성 보정` |
### 4.3 목표 3
| 항목 | 내용 |
| --- | --- |
| 목표명 | `안정화 및 운영 전환 준비` |
| 업무성격 | `자료조사` |
| 업무수행자 | `문형석 외 2명` |
| 계획기간 | `2026-08-17 ~ 2026-08-28` |
| 수행기간 | `2026-08-17 ~ 2026-08-28` |
| 예상 M/H | `36.0h` |
#### 업무 3.1
| 항목 | 내용 |
| --- | --- |
| 업무명 | `통합 테스트 및 예외 시나리오 점검` |
| 업무성격 | `기능구현` |
| 업무수행자 | `문형석 외 1명` |
| 계획기간 | `2026-08-17 ~ 2026-08-21` |
| 수행기간 | `2026-08-17 ~ 2026-08-21` |
| 비고 | `로그인, 검색, 조직도, 상세 기능 검증` |
세부업무:
| 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- |
| `핵심 기능 통합 테스트` | `2026-08-17 ~ 2026-08-19` | `2026-08-17 ~ 2026-08-19` | `로그인, 검색, 조직도, 상세 흐름 점검` |
| `예외 시나리오 및 오류 확인` | `2026-08-20 ~ 2026-08-21` | `2026-08-20 ~ 2026-08-21` | `실패 케이스, 오류 메시지, 재시도 확인` |
#### 업무 3.2
| 항목 | 내용 |
| --- | --- |
| 업무명 | `배포 환경 및 운영 체크리스트 정리` |
| 업무성격 | `내용정리` |
| 업무수행자 | `문형석` |
| 계획기간 | `2026-08-20 ~ 2026-08-25` |
| 수행기간 | `2026-08-20 ~ 2026-08-25` |
| 비고 | `배포 기준, 장애 대응, 운영 절차 정리` |
세부업무:
| 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- |
| `배포 기준 정리` | `2026-08-20 ~ 2026-08-22` | `2026-08-20 ~ 2026-08-22` | `배포 버전, 환경, 점검 항목 정리` |
| `운영 절차 및 장애 대응 정리` | `2026-08-23 ~ 2026-08-25` | `2026-08-23 ~ 2026-08-25` | `운영 절차, 문의 대응, 장애 체크 정리` |
#### 업무 3.3
| 항목 | 내용 |
| --- | --- |
| 업무명 | `시범 운영 및 보완사항 반영` |
| 업무성격 | `기능구현` |
| 업무수행자 | `문형석 외 2명` |
| 계획기간 | `2026-08-24 ~ 2026-08-28` |
| 수행기간 | `2026-08-24 ~ 2026-08-28` |
| 비고 | `피드백 반영 후 1차 마무리` |
세부업무:
| 세부업무명 | 계획기간 | 수행기간 | 설명 |
| --- | --- | --- | --- |
| `사용자 피드백 반영` | `2026-08-24 ~ 2026-08-26` | `2026-08-24 ~ 2026-08-26` | `시범 운영 중 확인된 보완사항 반영` |
| `1차 완료 정리` | `2026-08-27 ~ 2026-08-28` | `2026-08-27 ~ 2026-08-28` | `완료 보고 및 운영 전환 정리` |
## 5. 업무 관리 입력 초안
업무 관리 화면의 목표 제목은 아래처럼 넣으면 자연스럽다.
| 목표 | 입력 문안 |
| --- | --- |
| 목표 1 | `신규앱 인증/접속 기반 구축` |
| 목표 2 | `직원검색/조직도 핵심 기능 구현` |
| 목표 3 | `안정화 및 운영 전환 준비` |
### 5.1 확인사항
확인사항 카드에는 아래 문안이 적합하다.
#### 확인사항 1
- 제목: `Baron SSO 연계 정보 확정`
- 구분: `등록`
- 내용: `OIDC 연계값, Callback URL, 환경별 접속 경로 확정 필요`
#### 확인사항 2
- 제목: `조직/직원 데이터 기준 정합성 확인`
- 구분: `확인`
- 내용: `신규앱 노출 항목과 실제 조직 데이터 매핑 기준 확인 필요`
#### 확인사항 3
- 제목: `Android/iOS 배포 방식 및 운영 기준 협의`
- 구분: `공유`
- 내용: `Android/iOS 배포 경로와 운영 인수 기준 사전 협의 필요`
#### 확인사항 4
- 제목: `iOS 링크/배포 정책 확인`
- 구분: `확인`
- 내용: `iOS Universal Link, 서명, 배포 절차 적용 가능 여부 확인 필요`
## 6. 로드맵 요약 문안
로드맵 또는 상위 보고용으로는 아래 정도면 충분하다.
### 6.1 7월
- 인증 연계 구조 확정
- 앱 기본 실행 및 로그인 흐름 정비
- 개발/테스트 환경 기준 통일
### 6.2 8월
- 직원검색, 전화번호검색, 조직도, 상세 기능 집중 구현
- 즐겨찾기 및 주요 UX 보완
- iOS 로그인/링크 처리 및 배포 준비 항목 검토
### 6.3 8월 후반
- 통합 테스트
- 시범 운영
- 보완사항 반영 및 1차 완료
## 7. 변경사항 입력 초안
변경사항 탭에는 아래처럼 기록하면 무난하다.
| 일자 | 변경내용 |
| --- | --- |
| 2026-07-02 | 신규어플 프로젝트 등록 및 기본정보 입력 |
| 2026-07-10 | 인증 연계 구조 및 환경설정 범위 확정 |
| 2026-07-29 | 직원검색/조직도 1차 기능 개발 범위 반영 |
| 2026-08-21 | 통합 테스트 결과 및 보완사항 반영 |
| 2026-08-28 | 1차 구축 완료 및 운영 전환 기준 정리 |
## 8. 최종 추천 입력 세트
시간이 없으면 아래만 우선 입력해도 된다.
### 기본정보
- 프로젝트명: `TDC114 신규앱 구축`
- 시작일: `2026-07-02`
- 종료일: `2026-08-28`
- 분야: `시스템화`
- 관리상태: `정상`
- 수행팀: `IS 3팀`
- 업무수행자: `문형석 외 2명`
- 협업팀: `기술개발센터, Baron SSO 연계부서`
- 프로젝트 설명: `Baron SSO 연계 기반의 TDC114 신규 모바일 앱을 구축하여 직원검색, 전화번호검색, 조직도, 즐겨찾기 등 핵심 기능을 통합 제공하고 Android/iOS 공통 운영 기반과 iOS 대응 구조를 마련한다.`
- 기대 성과: `사내 직원검색 및 조직도 조회 업무를 모바일에서 일원화하고, Android/iOS 공통 신규앱 기반의 인증 및 운영 체계를 확보하여 사용자 접근성과 유지보수 효율을 높인다.`
### 대표 목표
1. `신규앱 인증/접속 기반 구축`
2. `직원검색/조직도 핵심 기능 구현`
3. `안정화 및 운영 전환 준비`
### 카드 표시용
- 현재업무: `Baron SSO 로그인 연계 및 직원검색/조직도 1차 구축`
- 목표일: `26.08.28 완료 목표`
## 9. 비고
- `수행팀`, `업무수행자`, `협업팀`은 실제 대시보드 선택값에 맞춰 마지막에만 조정하면 된다.
- 프로젝트명을 조금 더 실무형으로 보이게 하려면 `구축`, 조금 더 계속과제 느낌으로 보이게 하려면 `고도화`를 쓰면 된다.
- 핵심 추진 프로젝트 체크 여부는 팀 운영 방식에 따라 다르지만, 현재 성격상 체크하는 편이 자연스럽다.
@@ -12,7 +12,7 @@
| 항목 | 결정 |
| --- | --- |
| 로그인/가입 | 앱 자체 회원가입은 제공하지 않는다. Baron SSO에 이미 등록된 사용자만 사용 가능하다. |
| 앱 실행 로그인 | 사용자는 앱 실행 시 Baron SSO 로그인 방식으로 진입한다. 로그인창에 전화번호 입력 후 로그인 버튼을 누르면 Baron SSO 서버 등록 인원 여부를 확인하고, 등록 인원이면 앱 사용을 허용한다. |
| 앱 실행 로그인 | 사용자는 앱 실행 시 앱의 `Baron SSO 로그인` 버튼으로 Baron SSO Hosted Login 화면에 진입한다. 휴대폰번호 입력과 문자/메일 링크 인증은 Baron SSO 화면에서 처리하고, 인증 성공 후 App Link callback과 PKCE token 교환으로 앱 로그인을 완료한다. |
| 데이터 연계 | 신규 앱의 개인 정보와 조직 정보는 Baron SSO의 `orgFront` 데이터와 연계하여 추출한다. |
| 1차 제외 기능 | 기존 앱 기능 중 공지사항과 전자결재는 개발 중간 단계까지 구현을 보류한다. |
| 플랫폼 특화 제외 | 수신전화식별과 수신팝업은 1차 범위에서 보류한다. iOS에서 동일 방식 지원이 어렵고, 우선 기본 기능에 집중한다. |
@@ -0,0 +1,142 @@
# tdc114plus 테스트 자동화 스크립트 계획
작성일: 2026-07-02
상태: v1.0 초기 스크립트 기준
목적: `docs/00_policy_tdc114plus_testing_2026-07-02.md`에 정의한 테스트 정책을 실제 `scripts/` 파일과 연결하고, 즉시 사용 가능한 스크립트와 향후 구현이 필요한 scaffold 스크립트를 구분한다.
## 1. 즉시 사용 가능한 스크립트
| 스크립트 | 목적 | 실행 예 |
| --- | --- | --- |
| `scripts/format-dart.sh` | Docker Flutter 이미지에서 `dart format lib test` 실행 | `./scripts/format-dart.sh` |
| `scripts/quality-gate.sh` | `flutter analyze`, `flutter test` 순차 실행 | `./scripts/quality-gate.sh` |
| `scripts/api-smoke.sh` | `TDC114_API_BASE` 대상 Baron SSO 연동 API 최소 smoke test 실행. `TDC114_SMOKE_PHONE`이 있으면 `TDC114_SMOKE_AUTH_FLOW`에 따라 legacy `phone-login` 호환 경로 또는 headless 링크 흐름을 확인 | `TDC114_API_BASE=http://127.0.0.1:5000 ./scripts/api-smoke.sh` |
| `scripts/smoke.env.example` | authenticated smoke용 로컬 env 예시 파일. `scripts/.env.smoke.local`로 복사해 `TDC114_API_BASE`, `TDC114_SMOKE_PHONE` 값을 넣어 사용 | `cp scripts/smoke.env.example scripts/.env.smoke.local` |
| `scripts/smoke.staging.env.example` | staging Baron SSO 검증용 env 예시 파일. `scripts/.env.staging.local`로 복사해 `TDC114_API_BASE`, `TDC114_SMOKE_PHONE`, optional expected label 값을 넣어 사용 | `cp scripts/smoke.staging.env.example scripts/.env.staging.local` |
| `scripts/bootstrap-baron-api-env.sh` | Baron SSO API worktree의 `.env.sample`을 바탕으로 로컬 smoke용 `.env`를 생성하고 localhost/알림 비활성 override를 추가 | `./scripts/bootstrap-baron-api-env.sh` |
| `scripts/check-baron-api-env.sh` | Baron SSO API worktree의 `.env`, compose, config, Docker runtime 준비 상태를 점검해 API smoke 가능 여부를 빠르게 확인 | `./scripts/check-baron-api-env.sh` |
| `scripts/manual-postlogin-run.sh` | Android target에 `dart-define`을 포함한 `flutter run` 경로로 앱을 띄운다. 기본은 Baron SSO Hosted Login + PKCE 진입이며, 예외적으로만 legacy local `phone-login` bootstrap으로 post-login 상태를 seed 한다. 내부 호출은 `flutter-docker.sh run ...` 형태를 사용한다 | `TDC114_SMOKE_ENV_FILE=scripts/.env.android-device.local TDC114_FLUTTER_DEVICE_ID=<PHYSICAL_DEVICE_ID> ./scripts/manual-postlogin-run.sh` |
| `scripts/generate-release-report.sh` | git 상태와 릴리스 체크리스트 report 생성 | `./scripts/generate-release-report.sh` |
| `scripts/perf_smoke.sh` | 현재 앱/테스트 파일 수와 기본 상태 출력 | `./scripts/perf_smoke.sh` |
## 2. Scaffold 상태의 스크립트
아래 스크립트는 파일은 존재하지만, 기반 기능이 아직 없으므로 실행 시 scaffold 안내와 함께 종료한다.
| 스크립트 | 현재 상태 | 완성 조건 |
| --- | --- | --- |
| `scripts/mock-server.sh` | `status`, `stop`은 가능. `start`는 미구현 안내 | API 계약 기반 mock server 구현 |
| `scripts/save-snapshots.sh` | snapshot source가 있으면 report 경로로 복사 | widget/integration screenshot 또는 snapshot 생성 체계 |
| `scripts/integration_tests.sh` | `TDC114_API_BASE`를 Dart define으로 주입해 Android 기준 `flutter drive --driver=test_driver/integration_driver.dart --target=integration_test/app_smoke_test.dart`로 실행한다. Android preflight는 실기기 우선 기준이며 emulator는 fallback이다. `TDC114_SMOKE_PHONE`이 있으면 실제 로그인 smoke까지 확장한다. `TDC114_SMOKE_ASSUME_LOGGED_IN=1`일 때 기본 seed는 mock이고, legacy `phone-login` bootstrap은 명시적 예외 모드다 | `TDC114_API_BASE=http://127.0.0.1:5000 ./scripts/integration_tests.sh` |
| `scripts/redteam/run_all.sh` | 현재 AI 기능 없음 안내 | LLM/프롬프트 기반 기능이 실제 추가될 때 |
## 3. Phase별 사용 기준
### Phase 3: Mock 기반 1차 UI
필수:
```bash
./scripts/format-dart.sh
./scripts/quality-gate.sh
```
선택:
```bash
./scripts/save-snapshots.sh
```
단, snapshot 산출물이 생긴 뒤 사용한다.
### Phase 4: 실제 API 연동
필수 후보:
```bash
TDC114_API_BASE=https://staging.example.com ./scripts/api-smoke.sh
./scripts/check-baron-api-env.sh
TDC114_API_BASE=https://staging.example.com ./scripts/integration_tests.sh
```
현재 구현 상태:
- `app/integration_test/app_smoke_test.dart` scaffold 완료
- 로그인 화면 표시, 빈 전화번호 validation smoke는 항상 실행 가능
- `TDC114_SMOKE_PHONE`이 있으면 실제 로그인 후 직원검색 화면 진입 smoke까지 확장
- `scripts/.env.smoke.local`이 있으면 `TDC114_API_BASE`, `TDC114_SMOKE_PHONE`을 자동으로 읽는다
- `api-smoke.sh`는 optional `TDC114_SMOKE_AUTH_FLOW=phone-login|link`를 지원한다. staging 신규 승인 로그인 검증은 `link`를 사용한다
- staging 검증 시에는 `TDC114_SMOKE_ENV_FILE=scripts/.env.staging.local` 방식으로 별도 env 파일을 지정할 수 있다
- `scripts/integration_tests.sh`는 Android 기준 `flutter drive``test_driver/integration_driver.dart`를 사용해 현재 Flutter 버전의 unit/integration 혼합 실행 제한을 피한다
- `scripts/integration_tests.sh`는 Android target 정보가 주어지면 `scripts/check-android-device-env.sh`를 먼저 호출해 실기기/에뮬레이터 `offline`/`refused` 상태를 선제 차단한다
- `scripts/integration_tests.sh``No supported devices connected.` 실패를 만나면 Android 실기기 우선, emulator fallback 또는 추가 desktop/web runner 필요 안내를 함께 출력
- `scripts/integration_tests.sh`는 optional `TDC114_SMOKE_EXPECTED_NAME`, `TDC114_SMOKE_EXPECTED_TENANT_LABEL`을 Dart define으로 전달해 staging 계정 기준 기대 텍스트를 추가 검증할 수 있다
실환경 연동 완료 조건:
- Baron SSO backend 또는 staging API endpoint 실행
- Baron SSO `.env``config/` runtime 파일 준비
- 실제 로그인까지 확인할 경우 민감정보 없는 `TDC114_SMOKE_PHONE` 테스트 계정 준비
- staging 또는 local mock API endpoint 확정
- 민감정보 없는 test fixture 사용
staging 승인 로그인 검증 절차:
- 상세 시나리오는 `docs/scenario_staging_baron_sso_login_verification_2026-07-06.md`를 따른다.
### Phase 5: 핵심 액션
필수 후보:
```bash
./scripts/quality-gate.sh
./scripts/perf_smoke.sh
```
추가 예정:
- 전화걸기/문자보내기 URL 생성 테스트
- 즐겨찾기 로컬 저장소 테스트
### Phase 6: 빌드/배포 준비
필수 후보:
```bash
./scripts/generate-release-report.sh
```
추가 예정:
- Android debug APK build wrapper
- 수동 검증 체크리스트 자동 생성
## 4. 운영 원칙
- 문서에 명령을 추가할 때는 실제 `scripts/` 파일도 함께 추가하거나 scaffold 상태를 명시한다.
- scaffold 스크립트는 조용히 성공하지 않고, 미구현이면 non-zero exit code로 종료한다.
- 실제 CI gate에 연결할 수 있는 스크립트는 `quality-gate.sh`부터 시작한다.
- AI/LLM redteam 자동화는 현재 앱 범위 밖이므로 `redteam/run_all.sh`는 보류 상태로 유지한다.
- 자동화 스크립트 실행 결과는 `docs/test-logs/YYYY-MM-test-execution-log.md`에 월별로 누적 기록한다.
## 5. Playwright MCP 활용 예정
Playwright MCP는 향후 web/preview 기반 화면 확인이 가능해지는 시점부터 테스트 정책에 활용한다.
우선 적용 후보:
- 로그인 화면 smoke test
- 직원목록/검색/가족사 필터 화면 회귀 확인
- 직원 상세 화면 표시 확인
- screenshot 기반 UI 리뷰 자료 생성
- 텍스트 overflow, 주요 버튼 표시, 라우팅 이동 확인
현재는 Flutter web/preview 실행 방식이 확정되지 않았으므로 별도 `scripts/playwright-*` 파일은 만들지 않는다. 실행 방식이 확정되면 Playwright MCP 시나리오 문서와 월별 테스트 로그 기록 형식을 추가한다.
Playwright MCP 테스트 실행 전 절차:
1. 실행 가능한 Flutter web/preview 또는 Baron SSO 화면 대상이 준비되면 사용자에게 먼저 알리고 확인을 받는다.
2. 사용자 확인 후 `docs/00_policy_tdc114plus_testing_2026-07-02.md`와 본 문서에 테스트 정의, 절차, 성공 기준, 로그 기록 방식을 추가한다.
3. 문서 갱신 이후 Playwright MCP 테스트를 실행한다.
4. 실행 결과는 `docs/test-logs/YYYY-MM-test-execution-log.md`에 누적 기록한다.
@@ -0,0 +1,323 @@
# Android 앱 설치/실행 재발 방지 정책
작성일: 2026-07-06
상태: v1.4
목적: Windows Android Studio emulator + WSL/Docker Flutter 환경과 Android 공기계 USB 연결 환경에서 `tdc114plus` 앱을 테스트할 때, 환경값 누락과 ADB 연결 장애로 같은 문제를 반복하지 않도록 설치/실행 정책을 고정한다.
관련 문서:
- `docs/policy_android_studio_wsl_adb_2026-07-03.md`
- `docs/scenario_android_emulator_device_integration_test_2026-07-03.md`
- `scripts/README.md`
## 1. 이번 장애 요약
2026-07-06에 아래 문제가 재현되었다.
- 최신 코드를 빌드한 뒤 `adb install`로 debug APK만 직접 설치했다.
- 그러나 이 앱은 `TDC114_API_BASE` 같은 runtime 값을 `--dart-define`으로 받는다.
- APK 직접 설치 방식은 이 값을 전달하지 못한다.
- 결과적으로 앱은 기본값 `https://sso.example.invalid`를 바라보게 되었고, 직원 목록 API가 실패했다.
- 사용자는 코드가 반영되지 않았거나 필터 버그가 남아 있다고 오해할 수 있었다.
핵심 원인:
- 문제는 코드 수정 자체가 아니라 `설치 방식이 앱의 runtime config 구조와 맞지 않은 것`이었다.
## 2. 필수 원칙
- `tdc114plus` Android 수동 검증 기본 경로는 `flutter run`이다.
- `TDC114_API_BASE`가 필요한 앱 실행은 반드시 `dart-define` 포함 경로로 띄운다.
- `adb install` 또는 APK 파일 직접 설치는 기본 검증 경로로 사용하지 않는다.
- local Baron SSO 검증은 emulator 기준 `http://10.0.2.2:5000`을 사용한다.
- 앱/API 버그 판단 전에는 먼저 `./scripts/api-smoke.sh`로 backend 상태를 확인한다.
- Windows `adb.exe devices`에서 대상 emulator가 `device` 상태로 안정화되기 전에는 WSL/Docker Android 스크립트를 실행하지 않는다.
- 2026-07-08 기준 Windows Android Studio emulator + Docker Flutter 표준 연결 방식은 `ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037`이다.
- `TDC114_ADB_CONNECT_ADDRESS=<WINDOWS_HOST_IP>:<EMULATOR_PORT>` 직접 연결 방식은 Windows ADB 서버 공유 방식이 실패하거나 integration test의 VM service port forwarding 이슈가 확인된 경우에만 보조 경로로 사용한다.
- 2026-07-08 이후 신규 수동 기능점검의 기본 target은 가능하면 Android 공기계 USB 연결 방식으로 전환한다.
- emulator 경로는 보조/fallback으로 보존하되, 물리 키보드 한글 입력이나 emulator portproxy 문제를 해결하기 위해 emulator 설정을 반복하지 않는다.
- 공기계에서는 `10.0.2.2`를 사용할 수 없으므로 `adb reverse tcp:5000 tcp:5000` + `TDC114_API_BASE=http://127.0.0.1:5000`을 우선 사용한다.
- USB 없는 독립형 실기기 검증에서는 `TDC114_API_BASE=https://<staging-or-production-host>`를 사용하고 `TDC114_SKIP_SESSION_BOOTSTRAP=true`로 로컬 bootstrap/mocking을 끈다.
- 2026-07-08 이후 로그인과 데이터 소스를 분리해야 하면 `TDC114_AUTH_API_BASE`, `TDC114_DIRECTORY_API_BASE`, `TDC114_ORGANIZATION_API_BASE`를 별도로 지정할 수 있다. 값을 지정하지 않으면 모두 `TDC114_API_BASE`를 사용한다.
## 3. 허용 경로와 금지 경로
### 3.1 기본 허용 경로
공기계 USB 수동 기능점검을 우선한다.
```bash
ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037 \
TDC114_SMOKE_ENV_FILE=scripts/.env.android-device.local \
TDC114_FLUTTER_DEVICE_ID=<PHYSICAL_DEVICE_ID> \
./scripts/manual-postlogin-run.sh
```
사전 조건:
- Windows PowerShell `adb.exe devices`에서 공기계가 `device` 상태다.
- `adb reverse tcp:5000 tcp:5000`이 적용되어 있다.
- `scripts/.env.android-device.local``TDC114_API_BASE``http://127.0.0.1:5000`이다.
### 3.1-B USB 없는 독립형 실기기 허용 경로
staging 또는 production 공개 API에 직접 붙는 실기기 검증은 아래 경로를 사용한다.
```bash
ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037 \
TDC114_SMOKE_ENV_FILE=scripts/.env.android-device.staging.local \
TDC114_FLUTTER_DEVICE_ID=<PHYSICAL_DEVICE_ID> \
./scripts/manual-postlogin-run.sh
```
사전 조건:
- `TDC114_API_BASE``https://...` 공개 HTTPS Baron API 주소다.
- 필요 시 로그인은 staging, 직원/조직 데이터는 production으로 분리 주입할 수 있다.
- `TDC114_SKIP_SESSION_BOOTSTRAP=true`가 설정되어 있다.
- 로그인은 앱의 `Baron SSO로 로그인` 버튼에서 Hosted Login을 열고, App Link callback과 PKCE token 교환으로 완료한다.
- USB는 최초 설치/실행 후 분리 가능해야 한다.
### 3.1-A emulator fallback 허용 경로
emulator를 쓸 경우 아래 경로만 허용한다.
```bash
ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037 \
TDC114_SMOKE_ENV_FILE=scripts/.env.android-emulator.local \
./scripts/manual-postlogin-run.sh
```
용도:
- post-login 상태 수동 기능 점검
- local API base URL 포함 실행
- 실제 phone-login bootstrap 또는 mock fallback 포함 실행
실제 로그인 화면 검증이 필요하면 RP issuer/client/callback 값이 맞는지 확인한 뒤 `Baron SSO로 로그인` 버튼에서 외부 Hosted Login을 연다.
### 3.2 조건부 허용 경로
```bash
ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037 \
TDC114_SMOKE_ENV_FILE=scripts/.env.android-emulator.local \
./scripts/integration_tests.sh
```
용도:
- integration smoke
- post-login 가정 자동 검증
### 3.3 금지 경로
아래 방식은 정책상 기본 검증 경로로 금지한다.
```bash
adb install app-debug.apk
```
금지 이유:
- `TDC114_API_BASE`
- `TDC114_PREAUTH_*`
- `TDC114_SMOKE_USE_MOCK_DIRECTORY`
같은 runtime define 값이 전달되지 않는다.
예외:
- 단순 설치 가능 여부 확인
- 패키지명 확인
- manifest 수준 점검
이 경우에도 `기능 검증용 실행`으로 간주하지 않는다.
## 3-A. 직원검색 초기 기본값 정책
직원검색 첫 진입 시 기본값은 아래처럼 고정한다.
- 초기 조회 범위: 로그인 사용자의 회사급 범위
- 상단 기본 노출 칩: 회사급 칩 + 본인팀 칩
- 금지 동작: 앱 시작 직후 phone/name 재조회로 본인 1명 결과에 맞춰 초기 범위를 다시 팀 또는 개인 단위로 축소하는 로직
정책 이유:
- 첫 진입에서 사용자가 자기 자신만 보이면 디렉터리 앱 기본 UX와 맞지 않는다.
- 조직/회사 단위 탐색이 시작점이어야 하고, 본인팀은 빠른 재선택용 칩으로 유지하면 충분하다.
- `회사급 범위 조회``본인팀 칩 고정 노출`은 서로 다른 정책이므로 둘 다 함께 유지해야 한다.
## 4. 실행 전 체크 순서
Android 기능점검 전에는 아래 순서를 고정한다.
### 4.1 공기계 USB 기본 순서
1. Windows PowerShell에서 `adb.exe devices` 확인
2. 공기계가 `device` 상태인지 확인
3. `./scripts/api-smoke.sh` 통과 확인
4. `adb reverse tcp:5000 tcp:5000` 적용
5. `scripts/check-android-device-env.sh` 통과 확인
6. `manual-postlogin-run.sh` 실행
중단 기준:
- Windows `adb.exe devices`에서 공기계가 `unauthorized`이면 단말 RSA 승인 전까지 진행하지 않는다.
- `adb reverse`가 실패하면 LAN IP 방식으로 바꾸기 전에는 `127.0.0.1:5000` 기준 앱 실행을 하지 않는다.
- 앱에서 HTTP API가 차단되면 debug 전용 cleartext 허용 설정을 먼저 검토한다.
### 4.1-B USB 없는 독립형 실기기 순서
1. Windows PowerShell에서 `adb.exe devices` 확인
2. 공기계 또는 실사용 폰이 `device` 상태인지 확인
3. `TDC114_API_BASE=https://<staging-or-production-host>`로 env를 준비
4. `TDC114_SKIP_SESSION_BOOTSTRAP=true` 상태로 `manual-postlogin-run.sh` 실행
5. 앱 첫 화면에서 `Baron SSO로 로그인` 수행
6. 문자 또는 메일의 링크 승인 완료
7. `직원검색`, `organization/tenants`, `organization/orgchart` 화면 동작 확인
8. 앱이 열린 뒤 USB를 분리하고 같은 동작이 유지되는지 재확인
중단 기준:
- 공개 HTTPS base URL이 확정되지 않았으면 진행하지 않는다.
- Hosted Login, App Link callback, PKCE token 교환이 staging/production 환경에서 준비되지 않았으면 local mode로 되돌리지 않고 RP 설정과 서버 준비 상태를 먼저 맞춘다.
- 승인 링크 수신 채널이 준비되지 않았으면 USB 없는 독립 검증 완료로 간주하지 않는다.
### 4.2 emulator fallback 순서
1. Windows PowerShell에서 `adb.exe devices` 확인
2. 대상 emulator가 `device` 상태인지 확인
3. `./scripts/api-smoke.sh` 통과 확인
4. Windows ADB server `5037 -> 127.0.0.1:5037` portproxy와 방화벽 rule 확인
5. 그 다음 `manual-postlogin-run.sh` 또는 `integration_tests.sh` 실행
중단 기준:
- Windows `adb.exe devices`에서 대상 emulator가 `offline`이면 WSL/Docker 스크립트를 실행하지 않는다.
- Windows `adb.exe devices``device`인데 Docker local ADB 직접 연결이 `offline`이면, 직접 emulator port 연결을 반복하지 않고 `ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037` 방식으로 전환한다.
- Android Studio Device Manager의 표시 이름과 실제 AVD 폴더 이름이 달라도, `C:\Users\user\.android\avd`에 저장된 AVD 수와 Windows `adb.exe devices` 상태를 우선 기준으로 삼는다.
- AVD 프로세스가 `terminated`되거나 Android Studio가 `failed to connect within 5 minutes`를 표시하면 WSL/Docker 확인으로 넘어가지 않고 Windows 단독 emulator 부팅 안정화부터 처리한다.
완료 기준:
- 앱이 local API 기준으로 실행된다
- 직원검색 또는 로그인 화면이 기대한 경로로 열린다
## 5. 포트가 왜 바뀌는가
질문: 코드 수정이 생길 때마다 포트가 바뀌는가?
답: 아니다. `코드 수정 때문에 포트가 바뀌는 것이 아니다.`
포트가 바뀌는 이유는 보통 아래 중 하나다.
- 새 emulator 인스턴스를 띄웠다
- 기존 emulator를 끄고 다른 emulator 번호로 다시 띄웠다
- `emulator-5554`, `5556`, `5558`처럼 여러 인스턴스가 섞였다
- Windows `portproxy`가 그 emulator의 adbd port와 맞지 않았다
즉:
- 코드 변경
- Flutter rebuild
- hot reload
이 자체는 emulator port를 바꾸지 않는다.
## 6. 반복 절차를 줄이는 고정 운영안
반복을 줄이기 위해 아래 운영안을 기본값으로 사용한다.
### 6.1 1대 고정 원칙
- 수동 검증용 emulator는 한 번에 1대만 켠다.
- 기본 장비는 당일 Windows `adb.exe devices`에서 `device`로 확인된 emulator 1대다.
- 다른 emulator가 `offline`으로 남아 있거나 여러 인스턴스가 섞이면 Android Studio/ADB 상태를 먼저 정리한다.
효과:
- 어떤 emulator가 현재 대상인지 혼선이 줄어든다.
- `offline`/`refused` 원인 추적이 쉬워진다.
### 6.2 Windows ADB 서버 공유 원칙
- Docker/WSL Flutter는 기본적으로 Windows ADB server를 공유한다.
- 표준 환경변수는 `ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037`이다.
- Windows 관리자 PowerShell에서 `0.0.0.0:5037 -> 127.0.0.1:5037` portproxy와 방화벽 rule을 유지한다.
- Windows `adb.exe devices`에서 `emulator-5562 device`처럼 정상으로 보이면, Docker Flutter도 같은 Windows ADB server를 통해 장치를 인식해야 한다.
- 직접 emulator adbd port 연결이 `offline`으로 반복되면 해당 방식은 중단하고 Windows ADB server 공유 방식만 사용한다.
효과:
- Docker container 내부 ADB key 불일치로 인한 `offline`/`unauthorized` 반복을 줄인다.
- Windows에서 이미 정상 승인된 ADB 세션을 그대로 사용한다.
### 6.3 명시 emulator portproxy 원칙
- 수동 검증 포트는 Windows `adb.exe devices`에서 확인한 emulator port에 맞춘다.
- 예: Windows 대상이 `emulator-5562`이면 `5562 -> 127.0.0.1:5562` portproxy와 방화벽 규칙을 확인한다.
- `TDC114_ADB_CONNECT_ADDRESS=<WINDOWS_HOST_IP>:<EMULATOR_PORT>`는 보조 경로로만 명시한다.
- shutdown/startup 스크립트는 과거 고정 포트를 임의로 disconnect하지 않는다.
효과:
- 실제 대상 포트와 스크립트 포트가 어긋나는 일을 줄인다.
### 6.4 APK 직접 설치 금지
- 코드 수정 후 수동 검증은 항상 `flutter run` 또는 `manual-postlogin-run.sh`
- APK 직접 설치는 설치 확인용 보조 수단으로만 사용
효과:
- runtime define 누락 사고를 막는다.
### 6.5 같은 세션 재사용
- emulator를 켠 뒤 가능한 한 끄지 않는다.
- `flutter run` 세션이 살아 있을 때는 hot reload/hot restart를 우선한다.
- 큰 설정 변경이 아닐 때는 재설치보다 같은 세션 재사용을 우선한다.
효과:
- rebuild/install 시간과 ADB 재연결 횟수가 줄어든다.
## 7. 표준 명령
### 7.1 post-login 수동 기능점검
```bash
TDC114_SMOKE_ENV_FILE=scripts/.env.android-emulator.local \
ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037 \
./scripts/manual-postlogin-run.sh
```
### 7.2 integration smoke
```bash
TDC114_SMOKE_ENV_FILE=scripts/.env.android-emulator.local \
ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037 \
./scripts/integration_tests.sh
```
### 7.3 backend 상태 확인
```bash
./scripts/api-smoke.sh
```
## 8. 내일부터의 운영 기준
- Android 수동 검증 기본 target은 USB 연결 공기계다.
- Android emulator는 fallback target으로 보존한다.
- Docker/WSL 기본 연결은 `ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037`로 명시한다.
- `TDC114_ADB_CONNECT_ADDRESS=<WINDOWS_HOST_IP>:<EMULATOR_PORT>`는 직접 emulator port 연결이 필요한 예외 상황에만 사용
- 실행 스크립트 기본값은 `manual-postlogin-run.sh`
- `adb install`은 기능검증용 실행으로 인정하지 않음
이 기준을 어기면 다시 아래 오판이 발생할 수 있다.
- 코드 미반영으로 오해
- API 장애로 오해
- 필터 버그 미수정으로 오해
@@ -0,0 +1,303 @@
# Android Studio / WSL ADB 연동 재발 방지 정책
작성일: 2026-07-03
상태: v1.3 Windows Android Studio emulator + WSL/Docker Flutter 기준
목적: Windows Android Studio emulator를 WSL 및 Docker 기반 Flutter CLI에서 사용할 때 발생한 ADB 연동 지연을 반복하지 않도록, 지연 원인과 동일 상황 발생 시 우선 처리 방식을 정책으로 고정한다.
관련 문서:
- `docs/troubleshooting/android-studio-wsl-adb-timetable-260703.md`
- `docs/scenario_android_emulator_device_integration_test_2026-07-03.md`
- `docs/dev_env_tdc114plus_setup_plan_2026-07-02.md`
## 1. 적용 범위
본 정책은 아래 상황에 적용한다.
- Windows Android Studio에서 Android emulator를 실행한다.
- Flutter CLI는 WSL 또는 Docker container 내부에서 실행한다.
- WSL/Docker Flutter에서 Windows emulator 또는 Android device를 인식해야 한다.
- `scripts/flutter-docker.sh devices` 또는 `scripts/integration_tests.sh`가 Android target을 필요로 한다.
아래 상황은 본 정책의 직접 적용 대상이 아니다.
- macOS 기반 iOS simulator
- WSL 내부 Android emulator 직접 설치
- 실기기만 사용하고 Windows ADB server를 거치지 않는 구성
## 2. 기본 원칙
- Windows Android Studio emulator를 사용할 때는 Windows `adb.exe` 기준으로 먼저 device 상태를 확인한다.
- WSL에 Android SDK/ADB가 없다고 판단되면 WSL 내부 emulator 설치로 바로 우회하지 않는다.
- `adb -a -P 5037 nodaemon server` 방식은 1차 시도만 허용한다.
- `10048` bind 실패가 1회라도 재현되면 즉시 Windows `portproxy` 방식으로 전환한다.
- Docker Flutter에는 `ADB_SERVER_SOCKET`을 명시적으로 전달한다.
- 2026-07-08 확인 결과, Windows `adb.exe devices``device`인데 Docker local ADB 직접 연결이 `offline`으로 반복될 수 있으므로 기본 연결은 `ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037` 방식으로 한다.
- `TDC114_ADB_CONNECT_ADDRESS=<WINDOWS_HOST_IP>:<EMULATOR_PORT>` 기반 Docker local ADB server 방식은 `ADB_SERVER_SOCKET` 방식으로 integration test가 실제로 막힐 때만 보조 경로로 사용한다.
- Docker Flutter는 ADB key, Gradle, pub cache, Android SDK 하위 cache를 로컬 디렉터리에 유지한다.
- Android emulator에서 host API에 접근할 때는 `127.0.0.1`이 아니라 `10.0.2.2`를 우선 사용한다.
- Baron SSO backend, gateway, userfront, Ory runtime이 모두 살아 있는지 확인하기 전에는 Android 관련 테스트를 시작하지 않는다.
- integration test 실행 전에는 host 기준 `./scripts/api-smoke.sh`를 먼저 통과시킨다.
- 실행 결과와 장애 내용은 `docs/test-logs/YYYY-MM-test-execution-log.md`에 누적 기록한다.
## 3. 지연 원인
2026-07-03 작업에서 확인한 주요 지연 원인은 아래와 같다.
| 원인 | 영향 | 다음 대응 |
| --- | --- | --- |
| Windows ADB server가 `127.0.0.1:5037`에 먼저 바인딩됨 | WSL/Docker에서 직접 접근 불가 | `portproxy``0.0.0.0:5037 -> 127.0.0.1:5037` 노출 |
| Android Studio, Device Manager, emulator, ADB client가 ADB server를 자동 재기동 | 기존 PID 종료 후에도 `adb -a` 재시도 실패 반복 | `adb -a` 반복 시도 금지 |
| `adb -a -P 5037 nodaemon server``10048` 오류로 실패 | 외부 바인딩 방식 지연 | 1회 실패 후 `portproxy` 전환 |
| WSL 내부에 `adb`, `java`, `sdkmanager`, `emulator`가 없음 | WSL 단독 Android 환경 전환 불가 | Windows Android Studio emulator 사용 유지 |
| Docker container와 Windows ADB server의 localhost 의미가 다름 | integration test VM service dynamic port 연결 실패 가능 | 필요 시 당일 emulator adbd port를 노출 후 Docker local ADB server 방식 검증 |
| Docker local ADB server가 새 ADB key를 생성 | emulator가 `unauthorized` 상태로 표시 | Windows 승인 ADB key를 git ignored `.android-adb/`에 재사용 |
| Docker Flutter container가 매번 새로 생성됨 | NDK/CMake/Gradle/pub cache 재다운로드로 반복 지연 | `.docker-cache/flutter` 아래 cache volume 유지 |
| `scripts/integration_tests.sh` 출력이 종료 후 표시되는 구조였음 | Android 첫 빌드 진행 상태 확인 어려움 | 실시간 출력 방식 유지 |
## 4. 동일 상황 발생 시 처리 순서
### 4.1 Windows emulator 준비
1. Windows Android Studio를 실행한다.
2. Device Manager에서 emulator를 시작한다.
3. Windows PowerShell에서 `adb.exe devices`를 확인한다.
```powershell
& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" devices
```
완료 기준:
- `emulator-5554 device` 또는 동일한 Android target이 `device` 상태로 표시된다.
### 4.2 `adb -a` 1차 시도
필요 시 아래 방식을 1회만 시도한다.
```powershell
& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" -a -P 5037 nodaemon server
```
아래 결과가 나오면 더 반복하지 않는다.
- `10048`
- `0.0.0.0:5037` bind 실패
- 기존 ADB PID 종료 후에도 동일 실패 반복
### 4.3 `portproxy` 전환
관리자 PowerShell에서 아래 명령을 적용한다.
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=5037 connectaddress=127.0.0.1 connectport=5037
netsh advfirewall firewall add rule name="ADB 5037 for WSL" dir=in action=allow protocol=TCP localport=5037
netsh interface portproxy show v4tov4
```
WSL에서 Windows host IP를 확인한다.
```bash
awk '/nameserver/ {print $2; exit}' /etc/resolv.conf
```
이번 환경의 예시는 아래와 같다.
```bash
172.21.128.1
```
### 4.4 WSL/Docker 연결 확인
WSL에서 TCP 연결을 먼저 확인한다.
```bash
nc -vz <WINDOWS_HOST_IP> 5037
```
Docker Flutter에서 device 인식을 확인한다.
```bash
ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037 ./scripts/flutter-docker.sh devices
```
완료 기준:
- Android emulator가 Flutter devices 목록에 표시된다.
- Linux desktop만 보이면 Android target 준비가 완료되지 않은 상태로 판단한다.
### 4.4-A Flutter/Docker 표준 연결 방식
기본 device 인식, 앱 실행, APK 설치 확인은 아래 방식을 표준으로 사용한다.
```bash
ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037 ./scripts/flutter-docker.sh devices
```
정책 기준:
- `5037 remote Windows ADB server` 방식은 현재 Windows emulator 연동의 기본 경로다.
- Windows에서 이미 `device`로 승인된 ADB 세션을 Docker Flutter가 공유한다.
- Docker local ADB가 직접 emulator port에 붙었을 때 `offline`이 반복되면 더 반복하지 않는다.
- 직접 emulator port 연결은 아래 보조 경로로만 둔다.
보조 경로:
```bash
TDC114_ADB_CONNECT_ADDRESS=<WINDOWS_HOST_IP>:<EMULATOR_PORT> ./scripts/flutter-docker.sh devices
```
### 4.5 Android emulator API 주소
Android emulator에서 host machine API를 호출할 때는 아래 주소를 사용한다.
```bash
TDC114_API_BASE=http://10.0.2.2:5000
```
emulator용 local env 파일 예시는 아래와 같다.
```text
scripts/.env.android-emulator.local
```
민감정보가 포함될 수 있으므로 해당 파일은 git tracked 파일로 추가하지 않는다.
## 5. Integration test 추가 분기
`ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037` 방식은 device 목록 확인과 APK 설치까지는 가능할 수 있다. 다만 Flutter integration test loading 단계에서 VM service dynamic port 연결이 실패할 수 있다.
대표 증상:
```text
WebSocketChannelException
127.0.0.1:<dynamic port> connection refused
```
이 경우 원인은 remote Windows ADB server가 만든 port forward의 `127.0.0.1`이 Docker container 내부 localhost와 일치하지 않는 구조일 가능성이 높다.
동일 증상이 실제로 발생한 경우에만 아래 순서로 보조 경로 전환을 검토한다.
1. Windows 관리자 PowerShell에서 emulator adbd port를 노출한다.
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=<EMULATOR_PORT> connectaddress=127.0.0.1 connectport=<EMULATOR_PORT>
netsh advfirewall firewall add rule name="ADB emulator <EMULATOR_PORT> for WSL" dir=in action=allow protocol=TCP localport=<EMULATOR_PORT>
netsh interface portproxy show v4tov4
```
2. WSL에서 `<EMULATOR_PORT>` 연결을 확인한다.
```bash
nc -vz <WINDOWS_HOST_IP> <EMULATOR_PORT>
```
3. Docker container 내부 local ADB server가 emulator adbd에 직접 붙는지 확인한다.
```bash
docker run --rm ghcr.io/cirruslabs/flutter:stable sh -lc 'adb connect <WINDOWS_HOST_IP>:<EMULATOR_PORT> && adb devices'
```
4. 이 방식이 성공하면 `scripts/flutter-docker.sh` 또는 `scripts/integration_tests.sh`에 선택 환경변수를 추가한다.
예상 환경변수:
```bash
TDC114_ADB_CONNECT_ADDRESS=<WINDOWS_HOST_IP>:<EMULATOR_PORT>
```
5. Docker local ADB가 `unauthorized`이면 Windows에서 이미 승인된 ADB key를 재사용한다.
```bash
mkdir -p .android-adb
cp /mnt/c/Users/user/.android/adbkey .android-adb/adbkey.new
cp /mnt/c/Users/user/.android/adbkey.pub .android-adb/adbkey.pub.new
mv -f .android-adb/adbkey.new .android-adb/adbkey
mv -f .android-adb/adbkey.pub.new .android-adb/adbkey.pub
chmod 600 .android-adb/adbkey
```
6. 위 방식이 `device` 상태를 만들면 integration test는 아래 명령을 기본값으로 사용한다.
```bash
TDC114_SMOKE_ENV_FILE=scripts/.env.android-emulator.local \
TDC114_ADB_CONNECT_ADDRESS=<WINDOWS_HOST_IP>:<EMULATOR_PORT> \
./scripts/integration_tests.sh
```
## 5-A. API smoke 선행 원칙
Android integration test 전에 아래 명령을 먼저 실행한다.
```bash
./scripts/check-baron-api-env.sh
./scripts/api-smoke.sh
```
판정 기준:
- `./scripts/check-baron-api-env.sh`가 failure 0, warning 0 상태여야 한다.
- 최소한 아래 runtime이 running 또는 healthy 상태여야 한다.
- `baron_backend`
- `baron_gateway`
- `baron_userfront`
- `ory_kratos`
- `ory_hydra`
- `ory_keto`
- `ory_oathkeeper`
- `ory_postgres`
- phone-login, employee list, tenant list, orgchart가 모두 HTTP 200이어야 한다.
- unauthorized directory guard는 HTTP 401 또는 403이어야 한다.
실패 시 처리:
- `./scripts/check-baron-api-env.sh`에서 warning이 나오면 Android test보다 runtime 복구를 우선한다.
- `502 Bad Gateway`가 나오면 Baron/Ory runtime 중단 가능성을 먼저 의심한다.
- `./scripts/check-baron-api-env.sh`로 상태를 본다.
- `baron_backend`, `baron_userfront`, `ory_*` 컨테이너를 복구한 뒤 integration test를 재시도한다.
- `orgFront` 관련 API 확인이 필요한 경우에도 같은 원칙을 적용해 backend/gateway/userfront를 모두 확인한 뒤 진행한다.
## 5-B. Docker cache 유지 원칙
반복 실행 시 NDK/CMake/Gradle/pub cache 재설치를 막기 위해 `scripts/flutter-docker.sh`는 아래 로컬 cache 디렉터리를 유지한다.
- `.docker-cache/flutter/gradle`
- `.docker-cache/flutter/pub`
- `.docker-cache/flutter/android-sdk/licenses`
- `.docker-cache/flutter/android-sdk/ndk`
- `.docker-cache/flutter/android-sdk/cmake`
운영 원칙:
- 위 cache 디렉터리는 git tracked 파일로 추가하지 않는다.
- cache가 손상되지 않는 한 수동 삭제하지 않는다.
- Flutter Docker image를 바꾸더라도 우선 기존 cache와 호환되는지 확인한다.
- cache가 꼬여 비정상 빌드가 반복되면 해당 하위 디렉터리만 선별 삭제한다.
## 6. 금지 또는 제한 사항
- `adb -a -P 5037 nodaemon server` 실패 후 동일 명령을 여러 번 반복하지 않는다.
- Windows ADB PID를 계속 종료하면서 원인 확인 없이 시간을 쓰지 않는다.
- WSL에 `adb`, Java, Android SDK, `/dev/kvm` 조건이 없는데 WSL 내부 emulator 설치로 즉시 전환하지 않는다.
- 민감정보가 포함된 `.env.*.local` 파일을 git tracked 파일로 추가하지 않는다.
- `.android-adb/`, `.docker-cache/`를 git tracked 파일로 추가하지 않는다.
- Android emulator API base에 `http://127.0.0.1:5000`을 기본값으로 쓰지 않는다.
- `5037 remote ADB server` 방식에서 integration test VM service 실패가 재현됐는데 같은 방식만 반복하지 않는다.
## 7. 완료 체크리스트
다음 항목을 모두 만족하면 Android Studio / WSL ADB 연동 준비가 완료된 것으로 본다.
- Windows `adb.exe devices`에서 emulator가 `device` 상태다.
- `netsh interface portproxy show v4tov4``5037` mapping이 존재한다.
- integration test에서 직접 연결 보조 경로가 필요하면 `netsh interface portproxy show v4tov4`에 당일 `<EMULATOR_PORT>` mapping도 존재한다.
- WSL에서 `<WINDOWS_HOST_IP>:5037` TCP 연결이 성공한다.
- `ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037 ./scripts/flutter-docker.sh devices`에서 Android target이 표시된다.
- 보조 경로 사용 시 `TDC114_ADB_CONNECT_ADDRESS=<WINDOWS_HOST_IP>:<EMULATOR_PORT> ./scripts/flutter-docker.sh devices`에서 Android target이 `device` 상태로 표시된다.
- emulator용 API base가 `http://10.0.2.2:5000`으로 설정되어 있다.
- `./scripts/check-baron-api-env.sh`가 warning 없이 통과한다.
- host 기준 `./scripts/api-smoke.sh`가 통과한다.
- integration test에서 VM service dynamic port 실패가 발생하면 당일 `<EMULATOR_PORT>` local ADB server 방식으로 분기한다.
- `.android-adb/`, `.docker-cache/`가 로컬에 유지되고 git tracked 상태가 아니다.
- 결과를 `docs/test-logs/YYYY-MM-test-execution-log.md`에 기록한다.
+14
View File
@@ -0,0 +1,14 @@
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "kr.co.baron.tdc114plus",
"sha256_cert_fingerprints": [
"3A:2D:60:48:6F:E5:3A:84:0C:41:14:DC:3A:17:A4:3B:64:E7:DE:A5:10:4A:09:26:0E:DC:5F:49:88:44:71:76"
]
}
}
]
+14
View File
@@ -0,0 +1,14 @@
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "kr.co.baron.tdc114plus",
"sha256_cert_fingerprints": [
"3A:2D:60:48:6F:E5:3A:84:0C:41:14:DC:3A:17:A4:3B:64:E7:DE:A5:10:4A:09:26:0E:DC:5F:49:88:44:71:76"
]
}
}
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
삼안 직접 소속 판정 요약 (2026-07-16)
selected_tenant_name=삼안
selected_tenant_slug=saman
selected_tenant_memberCount_raw=0
selected_tenant_totalMemberCount_raw=None
current_app_direct_employee_count=1862
[현재 앱 직접 소속 판정식]
employee.tenantSlug == selectedTenant.slug
AND (employee.department is empty OR employee.department == selectedTenant.name)
[이번 삼안 데이터에서 걸린 이유]
- department_empty: 1862
[직급 상위 20]
- <empty>: 1845
- 부사장: 14
- 사장: 1
- 이사: 1
- 회장: 1
[직위 상위 20]
- <empty>: 1861
- 대표이사: 1
[판단 메모]
- 1862명 전원이 source_tenant_slug=saman에서 내려온 members다.
- 1862명 전원의 employee_department_raw가 빈 문자열이다.
- 따라서 현재 앱 로직에서는 1862명 전원이 직접 소속으로 분류된다.
- 이 집합에서 직위가 확인되는 값은 대표이사 1건뿐이며, 나머지 1861건은 직위가 비어 있다.
[대안별 예상 인원 수]
- 현재 앱 직접 소속 규칙 유지: 1862명
- 다른 하위조직 membership이 없는 루트 전용 인원만 사용: 17명
- 임원/대표이사급만 사용: 17명
- 이번 삼안 데이터에서는 `루트 전용 17명`과 `임원/대표이사급 17명`이 동일 집합이다.
@@ -0,0 +1,18 @@
employee_id,employee_name,employee_email,membership_count_across_tenants,has_non_saman_membership,non_saman_memberships
ba92e160-684c-4705-8b65-ff6087979b24,공병승,bskong@samaneng.com,1,N,
e8ce81d7-f682-4f7b-8ea8-75de62eac7ea,곽병구,bgkwak1@samaneng.com,1,N,
f3469d30-5c83-4171-b71e-93509feb5873,권현진,hjkwon2@samaneng.com,1,N,
26bc52b9-8e06-4a6d-935d-e857500dfaf3,김대수,dskim@samaneng.com,1,N,
3bf6cd28-480a-414e-8aae-62fddc6215ab,김봉식,bskim5@samaneng.com,1,N,
d6283634-9159-4ecd-b121-930b952b3ca5,김정만,jmkim1@samaneng.com,1,N,
995a96bc-ec0b-45a0-a780-d0a8198af86c,박승양,sypark@samaneng.com,1,N,
a1b4a6ea-d783-43f2-8ded-dd9400c9dd73,박호식,hspark3@samaneng.com,1,N,
63e8ad9f-2b6f-4faa-b8ae-3d47875748f5,서동석,dsseo1@samaneng.com,1,N,
bfefd442-f625-4537-8e72-56f919764ea1,서정택,jtseo@samaneng.com,1,N,
a5226e20-64a6-4882-8650-b1bd021fd925,신정하,jhshin2@samaneng.com,1,N,
fdbe4d03-221c-4966-b37e-fb4f8557420e,여형구,hgyeo@samaneng.com,1,N,
59aa19b6-3223-48e8-825c-ec0b8ab342df,정갑균,ggjeong@samaneng.com,1,N,
56ee2c75-0a33-440e-9852-7cb02d62d482,정홍섭,hsjeong2@samaneng.com,1,N,
c54a8119-e1b2-4767-90ca-d45b2eac65f8,조호연,hycho@samaneng.com,1,N,
fa3d0d72-e1cc-4101-8e95-dbe13a561a4f,최대선,dschoi@samaneng.com,1,N,
d0209fc9-dfbe-4411-ada2-3d02929f6b99,최동식,dschoi16@samaneng.com,1,N,
1 employee_id employee_name employee_email membership_count_across_tenants has_non_saman_membership non_saman_memberships
2 ba92e160-684c-4705-8b65-ff6087979b24 공병승 bskong@samaneng.com 1 N
3 e8ce81d7-f682-4f7b-8ea8-75de62eac7ea 곽병구 bgkwak1@samaneng.com 1 N
4 f3469d30-5c83-4171-b71e-93509feb5873 권현진 hjkwon2@samaneng.com 1 N
5 26bc52b9-8e06-4a6d-935d-e857500dfaf3 김대수 dskim@samaneng.com 1 N
6 3bf6cd28-480a-414e-8aae-62fddc6215ab 김봉식 bskim5@samaneng.com 1 N
7 d6283634-9159-4ecd-b121-930b952b3ca5 김정만 jmkim1@samaneng.com 1 N
8 995a96bc-ec0b-45a0-a780-d0a8198af86c 박승양 sypark@samaneng.com 1 N
9 a1b4a6ea-d783-43f2-8ded-dd9400c9dd73 박호식 hspark3@samaneng.com 1 N
10 63e8ad9f-2b6f-4faa-b8ae-3d47875748f5 서동석 dsseo1@samaneng.com 1 N
11 bfefd442-f625-4537-8e72-56f919764ea1 서정택 jtseo@samaneng.com 1 N
12 a5226e20-64a6-4882-8650-b1bd021fd925 신정하 jhshin2@samaneng.com 1 N
13 fdbe4d03-221c-4966-b37e-fb4f8557420e 여형구 hgyeo@samaneng.com 1 N
14 59aa19b6-3223-48e8-825c-ec0b8ab342df 정갑균 ggjeong@samaneng.com 1 N
15 56ee2c75-0a33-440e-9852-7cb02d62d482 정홍섭 hsjeong2@samaneng.com 1 N
16 c54a8119-e1b2-4767-90ca-d45b2eac65f8 조호연 hycho@samaneng.com 1 N
17 fa3d0d72-e1cc-4101-8e95-dbe13a561a4f 최대선 dschoi@samaneng.com 1 N
18 d0209fc9-dfbe-4411-ada2-3d02929f6b99 최동식 dschoi16@samaneng.com 1 N
@@ -0,0 +1,204 @@
# Android 공기계 로컬 USB 테스트 전환 검토
작성일: 2026-07-08
상태: 검토 완료
## 1. 목적
기존 Windows Android Studio emulator + WSL/Docker Flutter 기반 테스트는 유지하되, 앞으로의 기본 수동/통합 테스트 경로를 "집에서 가져온 Android 공기계 + 로컬 PC USB 연결" 방식으로 전환할 수 있는지 검토한다.
이번 검토의 범위는 아래와 같다.
- 현재 저장소에서 emulator 전용 가정이 어디에 있는지 확인
- 공기계 연결 방식으로 바꿀 때 유지 가능한 코드와 추가 필요한 보강점을 구분
- 실제 전환 시 가장 작은 변경 경로를 제안
## 2. 결론 요약
- 앱 코드 자체는 공기계 테스트로 전환 가능한 구조다. 실행 시점에 `TDC114_API_BASE``--dart-define`으로 주입하는 방식이라 target만 안정적으로 잡히면 emulator와 device를 공용으로 사용할 수 있다.
- 현재 가장 강하게 emulator에 묶여 있는 부분은 앱 로직이 아니라 운영 스크립트와 문서다.
- 공기계 전환의 핵심 이점은 Windows emulator portproxy, Docker local ADB, remote ADB server, VM service dynamic port 문제를 대부분 피할 수 있다는 점이다.
- 다만 실기기에서는 emulator 전용 주소 `10.0.2.2`를 쓸 수 없고, 로컬 HTTP 접근은 Android cleartext 정책에 막힐 가능성이 높다.
- 따라서 "기존 emulator 코드는 유지"하면서도, 앞으로는 실기기용 env/체크 스크립트/실행 절차를 별도로 추가하는 방식이 가장 안전하다.
## 3. 현재 코드/스크립트 구조에서 확인한 사항
### 3.1 target 선택 자체는 이미 공용화되어 있음
- `scripts/integration_tests.sh`
- `TDC114_FLUTTER_DEVICE_ID` 또는 `TDC114_ADB_CONNECT_ADDRESS`가 있으면 해당 target으로 실행한다.
- 즉, Flutter가 실기기를 인식하기만 하면 integration test 명령 자체는 재사용 가능하다.
- `scripts/manual-postlogin-run.sh`
- `TDC114_FLUTTER_DEVICE_ID` 또는 `TDC114_ADB_CONNECT_ADDRESS` 기반으로 `flutter run -d <device>`를 실행한다.
- 수동 점검 경로도 공기계 전환에 재사용 가능하다.
- `scripts/flutter-docker.sh`
- `ADB_SERVER_SOCKET`, `TDC114_ADB_CONNECT_ADDRESS`를 Docker에 전달한다.
- 구조상 device 연결 경로도 수용할 수 있다.
### 3.2 실제로는 preflight와 운영 정책이 emulator 기준임
- `scripts/check-android-emulator-env.sh`
- 이름부터 emulator 전용이다.
- Windows Android Studio, Device Manager, portproxy, emulator GUI 복구 절차를 전제로 한다.
- `scripts/startup.sh`
- 기본 Android precheck 스크립트가 `check-android-emulator-env.sh`로 고정돼 있다.
- 문서 다수
- `docs/checklist_morning_startup_runtime_2026-07-03.md`
- `docs/policy_android_studio_wsl_adb_2026-07-03.md`
- `docs/scenario_android_emulator_device_integration_test_2026-07-03.md`
- 위 문서들은 현재 운영 중심축이 emulator임을 보여준다.
### 3.3 emulator 전용 API 주소 가정이 남아 있음
- `scripts/integration_tests.sh`
- bootstrap용 base URL에서 `10.0.2.2 -> 127.0.0.1` 치환을 수행한다.
- `scripts/manual-postlogin-run.sh`
- 동일하게 `10.0.2.2 -> 127.0.0.1` 치환을 수행한다.
- 문서 전반
- emulator env는 `TDC114_API_BASE=http://10.0.2.2:5000`을 표준으로 본다.
이 부분은 "실기기에서 무엇을 쓸지"만 정하면 큰 문제는 아니다. 실기기는 아래 둘 중 하나면 된다.
- `adb reverse tcp:5000 tcp:5000``http://127.0.0.1:5000`
- 같은 LAN에서 `http://<PC_LAN_IP>:5000`
## 4. 공기계 전환 시 기대 효과
### 4.1 사라지거나 크게 줄어드는 문제
- Windows emulator GUI 상태 의존
- `5037` ADB server 공유 문제
- `5555`/`5557`/`5559` 같은 emulator adbd portproxy 관리
- Docker 내부 local ADB key와 Windows ADB key 불일치
- remote Windows ADB server가 만든 VM service dynamic port가 Docker localhost와 어긋나는 문제
즉, 지금까지 반복된 문제의 상당수는 "앱" 문제가 아니라 "Windows emulator를 WSL/Docker Flutter에서 원격으로 다루는 구조"에서 생겼다. USB 실기기는 이 복잡도를 상당히 낮춘다.
### 4.2 새로 관리해야 하는 문제
- 공기계의 USB 디버깅/RSA 승인 상태
- `adb reverse` 재설정 필요 여부
- 단말과 PC가 같은 네트워크인지 여부(LAN IP 방식일 때)
- Android의 cleartext HTTP 허용 여부
## 5. 가장 중요한 기술 리스크
### 5.1 Android cleartext HTTP 차단 가능성
현재 `app/android/app/src/main/AndroidManifest.xml`에는 아래가 없다.
- `android:usesCleartextTraffic="true"`
- debug용 `network_security_config`
로컬 Baron API는 현재 문서와 스크립트 기준으로 주로 `http://127.0.0.1:5000`, `http://10.0.2.2:5000`, `http://<PC_LAN_IP>:5000`를 사용한다. 실기기에서 debug APK를 띄웠을 때 Android 버전에 따라 cleartext가 차단될 수 있다.
따라서 공기계 전환 전에 가장 먼저 확인할 항목은 이것이다.
1. 공기계에서 `flutter run --dart-define=TDC114_API_BASE=http://127.0.0.1:5000` 또는 LAN IP로 실행
2. 로그인/디렉토리 API 호출이 실제로 되는지 확인
3. 차단되면 debug 전용 cleartext 허용 설정 추가
### 5.2 `adb reverse`와 Docker 컨테이너 내부 ADB의 관계
실기기에서 가장 단순한 API 접근 방식은 `adb reverse tcp:5000 tcp:5000`이다. 다만 현재 테스트 명령은 `scripts/flutter-docker.sh`를 통해 Docker 컨테이너 안에서 실행되는 경우가 많다.
검토 시점 기준으로 확인된 점:
- `flutter drive`/`flutter run`은 Docker 내부에서 수행된다.
- `adb reverse`를 누가 실행하느냐에 따라 적용 대상이 달라질 수 있다.
따라서 실무상 가장 안전한 기준은 아래 순서다.
1. 먼저 Windows 또는 WSL host에서 `adb devices`로 공기계가 `device` 상태인지 확인
2. 같은 ADB 경로에서 `adb reverse tcp:5000 tcp:5000` 실행
3. 이후 Docker Flutter가 동일 device를 보는지 확인
만약 Docker 내부 ADB와 host ADB가 서로 다른 서버/세션을 쓰면 `adb reverse`가 예상대로 먹지 않을 수 있다. 그 경우에는 LAN IP 방식을 백업 경로로 잡는 것이 안전하다.
## 6. 변경 영향 검토
### 6.1 그대로 재사용 가능한 것
- `app/` 내부 Dart 앱 코드 대부분
- `app/integration_test/app_smoke_test.dart`
- `app/test_driver/integration_driver.dart`
- `scripts/api-smoke.sh`
- `scripts/check-baron-api-env.sh`
- `scripts/integration_tests.sh`의 기본 실행 구조
- `scripts/manual-postlogin-run.sh`의 post-login seed 구조
### 6.2 공기계 기준으로 별도 추가하는 편이 좋은 것
- 실기기 전용 env 예시
- `scripts/.env.android-device.local`는 이미 문서상 가정만 있고 tracked example은 부족하다.
- 실기기 preflight 스크립트
- 예: `scripts/check-android-device-env.sh`
- 실기기 실행 가이드 문서
- USB 디버깅, RSA 승인, `adb reverse`, LAN IP fallback 포함
### 6.3 나중에 일반화하면 좋은 것
- `check-android-emulator-env.sh`를 유지한 채, 상위 래퍼 `check-android-target-env.sh`를 만들어
- `TDC114_ANDROID_TARGET_KIND=emulator|device`
- 또는 env 유무 기준으로 분기
- `startup.sh`에서 precheck script를 교체 가능하게 유지하되, 기본 정책 문구를 "Android target" 기준으로 일반화
## 7. 권장 전환 방식
기존 emulator 코드는 그대로 두고 아래 순서로 가는 것을 권장한다.
1. 이번 테스트까지만 기존 emulator 경로 사용
2. 그 다음부터는 공기계를 기본 target으로 사용
3. 초기에는 `manual-postlogin-run.sh` 중심으로 수동 기능점검부터 안정화
4. 그 다음 `integration_tests.sh`를 공기계 target으로 재사용
5. 충분히 안정화된 뒤에만 `startup.sh` 기본 Android precheck를 실기기 기준으로 확장
## 8. 최소 실행 초안
### 8.1 공기계 수동 점검
```bash
# 1) 공기계 USB 연결 후 host에서 device 확인
adb devices
# 2) reverse 방식 선택 시
adb reverse tcp:5000 tcp:5000
# 3) 실기기용 env 준비
# scripts/.env.android-device.local
TDC114_API_BASE=http://127.0.0.1:5000
TDC114_SMOKE_PHONE=010xxxxxxxx
# 4) 앱 실행
TDC114_SMOKE_ENV_FILE=scripts/.env.android-device.local \
TDC114_FLUTTER_DEVICE_ID=<physical_device_id> \
./scripts/manual-postlogin-run.sh
```
### 8.2 공기계 integration test
```bash
TDC114_SMOKE_ENV_FILE=scripts/.env.android-device.local \
TDC114_FLUTTER_DEVICE_ID=<physical_device_id> \
./scripts/integration_tests.sh
```
LAN IP 방식을 쓰는 경우 env의 `TDC114_API_BASE``http://<PC_LAN_IP>:5000`로 바꾼다.
## 9. 최종 판단
공기계 전환은 타당하다. 현재 막힌 문제의 대부분은 emulator 자체보다도 "Windows emulator + WSL/Docker Flutter + 원격 ADB/portproxy" 조합에서 발생했다.
따라서 다음 기본 방침은 합리적이다.
- emulator 경로는 보존
- 앞으로의 기본 테스트는 공기계 USB 연결 방식으로 전환
- 초기 목표는 "수동 post-login 점검 안정화"
- 그 다음 "integration_tests.sh 공기계 재사용"
단, 실전 전환 전에 반드시 먼저 확인할 항목은 아래 2개다.
1. 공기계에서 local HTTP API가 cleartext 차단 없이 실제 호출되는가
2. `adb reverse`가 Docker Flutter 실행 경로에서도 안정적으로 유지되는가
이 2개가 통과하면 emulator 대비 운영 복잡도는 확실히 낮아질 가능성이 높다.
@@ -0,0 +1,159 @@
# Baron SSO 레포/커밋 운영 검토
작성일: 2026-07-15
상태: review
목적: `tdc114plus` 앱 저장소와 Baron SSO 관련 소스코드의 저장소 분리 여부, 커밋 정책, 로컬 빌드 부담 완화 방안을 함께 검토한다.
## 1. 현재 상태 요약
- `tdc114plus`는 현재 별도 Gitea 저장소(`https://gitea.hmac.kr/kevin/tdc114plus.git`)에서 관리 중이다.
- 앱 저장소의 정책 문서에는 이미 `Baron SSO backend/orgFront 변경과 tdc114plus Flutter 앱 변경은 저장소와 커밋을 분리한다`는 원칙이 들어 있다.
- Baron SSO 참조 기준 문서에는 공식 Baron SSO 저장소 `origin/dev`를 기준으로 보고, 실제 수정이 필요하면 `/home/ubuntu/workspace/baron-sso-tdc114plus-api` worktree의 `feature/tdc114plus-api` 브랜치에서 작업하도록 정리돼 있다.
- 즉, 방향성 자체는 이미 “분리 관리” 쪽으로 잡혀 있으나, Gitea 상의 장기 운영 단위와 커밋 연결 규칙은 아직 명문화가 약하다.
## 2. 검토 결론
### 2.1 Baron SSO 관련 별도 Gitea 저장소 생성 필요 여부
결론:
- "완전히 새로운 독립 저장소"를 지금 즉시 만들어야 하는 수준의 필수 사항은 아니다.
- 다만 `tdc114plus` 대응용 Baron SSO 변경을 장기간 추적할 계획이라면, Gitea 상에서 팀이 명확히 보이는 관리 단위는 꼭 두는 편이 좋다.
권장안:
1. 최우선 권장: 기존 Baron SSO 공식 저장소를 기준으로 하고, 팀/개인 fork에서 `feature/tdc114plus-*` 브랜치 규칙으로 관리한다.
2. 차선 권장: Baron SSO 수정이 계속 누적되고 담당자/릴리스 이력이 분리되어야 하면, Gitea에 `baron-sso-tdc114plus` 성격의 전용 fork 또는 전용 미러 저장소를 둔다.
3. 비권장: Baron SSO 코드를 `tdc114plus` 앱 저장소 안으로 복사하거나 subtree/submodule처럼 강결합해 같이 버전 관리한다.
판단 이유:
- 현재 문서와 스크립트는 이미 Baron SSO를 외부 worktree로 전제하고 있다.
- 앱 저장소와 Baron SSO 저장소를 섞으면 커밋 단위, 리뷰 단위, 배포 책임이 흐려진다.
- 반대로 완전 별도 저장소를 새로 만들더라도 upstream 동기화 책임이 생기므로, 운영 이점이 분명할 때만 택하는 것이 좋다.
### 2.2 커밋 정책 수립 필요 여부
결론:
- 필요하다.
- 특히 앱 저장소와 Baron SSO 저장소를 동시에 건드리는 작업이 이미 발생할 수 있으므로, "무엇을 한 커밋으로 묶고 무엇을 분리할지"를 바로 정해야 한다.
## 3. 권장 운영 정책
### 3.1 저장소 경계 정책
- `tdc114plus` 저장소:
- Flutter 앱 코드
- 앱 문서
- 앱 테스트/빌드 스크립트
- 앱에서만 사용하는 mock/contract 코드
- Baron SSO 저장소 또는 fork:
- backend
- orgFront
- userFront
- 신규 앱 지원용 API endpoint, DTO, auth 연동 코드
금지:
- Baron SSO 소스 파일을 `tdc114plus` 저장소로 복사 반입
- APK, 로그 덤프, 대용량 캐시 산출물을 tracked 파일로 커밋
- 한 커밋에 앱 코드와 Baron SSO 서버 코드를 동시에 포함
### 3.2 브랜치 정책
- `tdc114plus`:
- `main`: 항상 analyze/test 통과 상태만 반영
- 기능 작업: `feature/<scope>-<topic>`
- 문서/운영 작업: `docs/<topic>`, `ops/<topic>`
- Baron SSO:
- 기준 브랜치: `origin/dev`
- tdc114plus 전용 작업: `feature/tdc114plus-api`, `feature/tdc114plus-auth`, `fix/tdc114plus-org-context`
### 3.3 커밋 단위 정책
한 커밋에는 아래 중 한 가지 성격만 담는다.
1. 앱 기능 변경
2. 앱 리팩터링
3. 테스트 추가/수정
4. 문서/운영 스크립트 변경
5. Baron SSO API 변경
권장 규칙:
- 기능 변경과 포맷 변경을 섞지 않는다.
- 리네임/이동 커밋과 로직 변경 커밋을 가능하면 분리한다.
- APK 빌드 결과물, 캡처 이미지, 임시 로그는 별도 보관하고 git tracked 대상에서 제외한다.
- 앱과 서버를 함께 바꿔야 하면 저장소별로 각각 커밋하고, 커밋 메시지 본문에 상대 저장소 커밋 해시를 남긴다.
커밋 메시지 예시:
```text
feat(auth): add hosted login callback handling
```
```text
fix(directory): align org-context subtree parsing with swagger
```
```text
docs(ops): add Android device startup checklist
```
```text
feat(baron-api): add tdc114plus org-context support endpoint
```
교차 저장소 작업 시 본문 예시:
```text
Related-Baron-Commit: abc1234
Related-App-Commit: def5678
```
### 3.4 main 반영 게이트
`tdc114plus` 저장소는 `main` 반영 전에 최소 아래를 권장한다.
```bash
./scripts/flutter-docker.sh analyze
./scripts/flutter-docker.sh test
```
Baron SSO 저장소는 팀 표준 게이트를 따르되, 최소한 아래 둘 중 하나는 남기는 편이 좋다.
- API smoke 결과
- 변경 endpoint 수동 검증 로그 또는 문서 링크
## 4. 로컬 PC 성능 저하 및 네트워크 끊김 이슈 대응
현재 문제:
- APK 빌드나 대형 파일 변경 작업 중 로컬 PC 자원이 크게 소모되면서 네트워크 접속이 끊긴다.
- 이 경우 원격 작업 세션, 빌드 검증, 로그 수집이 함께 불안정해질 수 있다.
권장 대응:
1. APK 빌드와 통합 검증은 로컬 PC보다 현재처럼 Docker/WSL/원격 작업공간 기준으로 우선 수행한다.
2. 로컬 PC는 편집/간단 확인 위주로 쓰고, 무거운 빌드/테스트는 스크립트로 표준화된 원격 환경에서 수행한다.
3. 빌드 결과물은 git에 올리지 말고, 필요 시 릴리스 산출물 저장 위치를 별도로 둔다.
4. `main` 반영 기준을 "로컬에서 한번 실행"이 아니라 "표준 스크립트 실행 결과 확인"으로 바꾼다.
5. 장기적으로는 Gitea 연동 CI 또는 별도 빌드 머신을 두어 APK 생성과 smoke test를 오프로드하는 것이 가장 효과적이다.
## 5. 바로 실행할 추천안
우선순위 순서:
1. Baron SSO는 새 독립 저장소를 바로 만들기보다, 현재 공식 저장소 + fork/worktree 체계를 팀 표준으로 먼저 확정한다.
2. `tdc114plus`와 Baron SSO 각각에 브랜치 접두사와 커밋 메시지 규칙을 정한다.
3. 교차 저장소 작업 시 서로의 커밋 해시를 본문에 남기는 규칙을 추가한다.
4. `main` 반영 전 검증 명령을 문서상 필수 게이트로 고정한다.
5. APK 빌드는 로컬 PC가 아닌 원격/Docker 기준으로 수행하는 운영 정책을 확정한다.
## 6. 최종 판단
- Baron SSO 관련 코드는 분리 관리가 맞다.
- 다만 "신규 독립 레포 생성"은 필수라기보다 운영 선택지이며, 먼저 fork/worktree + 브랜치 정책만 명확히 해도 상당수 문제가 해결된다.
- 지금 가장 시급한 것은 저장소 추가보다 커밋 경계, 교차 저장소 연결 방식, 빌드 오프로드 정책을 확정하는 일이다.
@@ -0,0 +1,51 @@
# tdc114plus docs 바로 하위 주요 문서 전수 검토 기록
작성일: 2026-07-10
상태: 검토 완료
목적: `docs/` 바로 하위 `.md` 문서를 신규 개발 기준에 맞춰 전수 검토하고, 코드 수정 전 적용해야 할 정책/가이드 보정 사항을 기록한다.
## 1. 검토 기준
- 신규 앱은 Baron SSO에 등록되는 별도 모바일 RP다.
- 로그인 기본 방식은 `Baron SSO Hosted Login + OIDC Authorization Code + PKCE`다.
- Flutter 앱은 headless API를 직접 호출하지 않는다.
- 조직/직원 데이터 기준 원본은 `GET https://sadmin.hmac.kr/api/v1/integrations/org-context`다.
- 조직 drilldown은 `자식 조직이 있으면 하위조직 목록`, `leaf 조직이면 직원 목록`을 따른다.
- 하위조직 카드 인원 수는 직접 소속 수가 아니라 subtree 합산 인원 수를 표시한다.
## 2. 검토 범위
`docs/` 바로 하위 `.md` 31개를 기준으로 검토했다.
- `00_` 계약/정책/가이드 문서 전체
- Android/ADB/실기기 운영 체크리스트
- Baron org-context 참고 문서
- staging 로그인 시나리오
- 기존 정책 검토/전환 리뷰 문서
하위 폴더(`docs/references`, `docs/test-logs`, `docs/troubleshooting`, `docs/daily-issues`)는 이번 범위에서 제외했다.
## 3. 확인 결과
- 핵심 계약 문서(`00_contract_tdc114plus_api_2026-07-02.md`)는 Hosted Login + PKCE 기준과 일치한다.
- 화면 정책 문서(`00_policy_tdc114plus_screen_feature_2026-07-07.md`)는 leaf/비-leaf 조직 표시 규칙과 subtree 인원 수 정책을 포함하도록 정리되어 있다.
- 외부 API 사용 문서(`00_guide_tdc114plus_external_api_usage_2026-07-03.md`)는 운영 Key를 앱에 넣지 않는다는 원칙과 일치한다.
- org-context 매핑 문서에는 `memberCount``totalMemberCount` 차이를 더 명확히 보강했다.
- 쉬운 설명 문서에는 headless 직접 호출 중심 설명이 남아 있어 Hosted Login + PKCE 기준으로 개정했다.
- staging 검증 시나리오에는 headless API 배포 전제가 남아 있어 Hosted Login + PKCE 검증 기준으로 개정했다.
- Android/ADB/실기기 문서는 현재 USB 실기기 + Windows ADB 서버 공유 운영 기준과 충돌하지 않는다.
## 4. 이번에 개정한 문서
- `docs/00_policy_tdc114plus_screen_feature_2026-07-07.md`
- `docs/00_guide_tdc114plus_org_context_mapping_2026-07-08.md`
- `docs/guide_baron_org_context_api_reference_2026-07-03.md`
- `docs/guide_new_app_baron_sso_easy_explanation_2026-07-08.md`
- `docs/scenario_staging_baron_sso_login_verification_2026-07-06.md`
## 5. 코드 수정 판단
현재 화면 정책상 `CM본부 -> CM사업부 -> 호남지역총괄본부` 흐름에서 `호남지역총괄본부 0명`은 API 데이터가 leaf 직접 소속 0명이라면 정상일 수 있다.
다만 코드상 선택 조직의 직원 검색 범위가 선택 조직 자신만 보고 descendant tenant를 포함하지 않는 문제가 있다. 따라서 다음 코드 수정은 `현재 선택 scope의 subtree 전체를 직원 검색 범위로 사용`하도록 보완한다.
@@ -0,0 +1,87 @@
# tdc114plus 정책 문서 개편 검토
작성일: 2026-07-07
상태: review v1.0
목적: `docs/` 아래 기존 정책/계약/절차 문서가 새로 수립한 `분리형 API 전환 정책` 기준으로 수정 가능한지 검토하고, 문서별 후속 조치 방향을 정리한다.
관련 기준 문서:
- `docs/00_policy_tdc114plus_decoupled_api_migration_2026-07-07.md`
백업 위치:
- `/home/ubuntu/workspace/tdc114plus/temp/docs-policy-backup/`
## 1. 결론
- 기존 문서들은 대부분 `수정 가능`하다.
- 다만 전부를 새로 쓰는 것은 아니고, 문서 성격에 따라 `전면 개정`, `부분 개정`, `유지`, `기록 보존`으로 나눠야 한다.
- 특히 `개발 정책`, `API 계약`, `테스트 정책`, `작업 타임테이블`, `외부 API 사용 가이드`는 새 정책 기준으로 반드시 재정렬하는 것이 맞다.
- 반대로 Android 설치/ADB/체크리스트류 문서는 분리형 API 정책과 직접 충돌하지 않으므로 기본 유지가 맞다.
## 2. 문서별 판정
| 문서 | 판정 | 이유 | 권장 조치 |
| --- | --- | --- | --- |
| `docs/00_policy_tdc114plus_development_2026-07-02.md` | 전면 개정 권장 | Baron SSO API 생성 중심 문구가 강하고 Swagger 중심 인터페이스 개발 원칙이 상위 기준으로 드러나지 않음 | 새 정책을 반영해 상위 실행 원칙 문구 재작성 |
| `docs/00_contract_tdc114plus_api_2026-07-02.md` | 전면 개정 권장 | 현재 계약이 Baron SSO 전용 설계와 내부 추정 구조에 많이 기대고 있음 | Swagger 기준 endpoint/DTO 문서로 재작성 |
| `docs/00_policy_tdc114plus_testing_2026-07-02.md` | 부분 개정 권장 | 테스트 구조는 유효하지만 실제 API 연동 기준이 Baron SSO runtime 중심임 | mock 우선, Swagger 계약 검증, remote/mock 전환 테스트 기준 추가 |
| `docs/00_guide_tdc114plus_work_progress_timetable_2026-07-02.md` | 부분 개정 권장 | 기존 진행 이력은 보존 가치가 있으나 실행 Phase가 이전 방식 중심으로 적혀 있음 | 새 정책 Phase 기준으로 다음 작업 구간만 재정렬 |
| `docs/00_guide_tdc114plus_external_api_usage_2026-07-03.md` | 부분 개정 권장 | 외부 API 활용 방식 문서이므로 새 정책과 잘 결합 가능 | Swagger 우선 사용 원칙과 data source 분리 기준 추가 |
| `docs/guide_baron_sso_reference_source_2026-07-02.md` | 부분 개정 권장 | 완전 삭제할 문서는 아니지만, 이제는 Baron SSO 코드 참조 문서이지 앱 개발 상위 기준 문서는 아님 | 제목/목적을 `참조용 문서` 성격으로 낮추고 우선순위 하향 |
| `docs/00_policy_tdc114plus_screen_feature_2026-07-07.md` | 부분 개정 가능 | 화면 정책 자체는 유효하지만 일부 용어가 Baron SSO 전제에 묶여 있음 | 화면 규칙은 유지하고 API/세션 관련 표현만 일반화 |
| `docs/dev_env_tdc114plus_setup_plan_2026-07-02.md` | 기록 보존 중심 | 초기 환경 구축 기록 성격이 강하고 현재는 실행 정책 문서라기보다 이력 문서에 가까움 | 원본 유지, 필요 시 현재 환경 기준 별도 문서 신설 |
| `docs/guide_tdc114plus_development_decision_brief_2026-07-01.md` | 기록 보존 중심 | 초기 판단 근거 문서라 현재 정책으로 덮어쓰는 대상이 아님 | 원본 유지 |
| `docs/guide_tdc114plus_script_automation_plan_2026-07-02.md` | 부분 개정 가능 | 자동화 계획은 계속 유효하나 검증 대상을 Swagger 계약 기준으로 보강할 필요가 있음 | smoke/test 분리를 명확히 보강 |
| `docs/policy_android_app_install_execution_2026-07-06.md` | 유지 | API 분리 정책과 직접 충돌 없음 | 유지 |
| `docs/policy_android_studio_wsl_adb_2026-07-03.md` | 유지 | 개발 장비/실행 정책 문서라 독립적임 | 유지 |
| `docs/scenario_android_emulator_device_integration_test_2026-07-03.md` | 부분 개정 가능 | 시나리오는 유효하나 API 대상 URL/테스트 전제가 바뀔 수 있음 | 실제 API 대상 설명만 갱신 |
| `docs/checklist_pre_staging_manual_2026-07-06.md` | 부분 개정 가능 | staging 체크리스트는 유지 가능하나 기준 API가 Swagger 중심으로 바뀌어야 함 | 체크 항목 갱신 |
| `docs/scenario_staging_baron_sso_login_verification_2026-07-06.md` | 부분 개정 가능 | 로그인 검증 흐름은 유효하지만 Baron SSO 내부 구현 전제가 강함 | 인터페이스 계약 기준으로 표현 수정 |
| `docs/checklist_morning_startup_runtime_2026-07-03.md` | 유지 | 운영성 체크리스트라 정책 변경 영향이 낮음 | 유지 |
| `docs/checklist_evening_shutdown_runtime_2026-07-06.md` | 유지 | 운영성 체크리스트라 정책 변경 영향이 낮음 | 유지 |
| `docs/guide_baron_org_context_api_reference_2026-07-03.md` | 참조용 유지 | 특정 외부 연동 참고자료로 쓸 수 있음 | 정책 문서가 아닌 reference로 유지 |
| `docs/guide_baron_sso_org_context_mirror_recovery_request_2026-07-03.md` | 기록 보존 | 이슈 대응 기록 문서임 | 유지 |
## 3. 우선 개편 대상
가장 먼저 손대야 할 문서는 아래 다섯 개다.
1. `docs/00_policy_tdc114plus_development_2026-07-02.md`
2. `docs/00_contract_tdc114plus_api_2026-07-02.md`
3. `docs/00_policy_tdc114plus_testing_2026-07-02.md`
4. `docs/00_guide_tdc114plus_work_progress_timetable_2026-07-02.md`
5. `docs/00_guide_tdc114plus_external_api_usage_2026-07-03.md`
이유:
- 새 정책의 실행력은 위 다섯 문서가 실제로 같은 방향을 보느냐에 달려 있다.
- 특히 `개발 정책``API 계약`이 이전 기준에 남아 있으면 이후 코드 작업도 다시 Baron SSO 종속 방식으로 흔들릴 수 있다.
## 4. 수정 방식 제안
권장 순서:
1. `tdc114plus-development-policy`를 먼저 고친다.
2. 그 다음 `tdc114plus-api-contract`를 Swagger 기준으로 재작성한다.
3. 이후 `tdc114plus-testing-policy`를 mock/contract/real API 3단계 구조로 맞춘다.
4. `tdc114plus-work-progress-timetable`의 다음 작업 구간을 새 Phase 기준으로 정리한다.
5. 마지막으로 `external-api-usage-guide`, `screen-feature-policy`, staging 체크리스트 문서를 정렬한다.
## 5. 해석 원칙
- 기존 문서에 적힌 과거 진행 이력은 가능한 보존한다.
- 다만 앞으로의 실행 기준은 `docs/00_policy_tdc114plus_decoupled_api_migration_2026-07-07.md`를 상위 기준으로 둔다.
- Baron SSO 관련 문구가 있어도 `참조`, `연동 대상`, `과거 구현 근거` 수준이면 유지 가능하다.
- Baron SSO 소스 수정 또는 Baron SSO 전용 endpoint 추가를 기본 개발 방식처럼 서술한 부분은 새 정책에 맞춰 수정해야 한다.
## 6. 다음 문서 작업 제안
다음 실제 수정 작업은 아래 순서가 가장 적절하다.
1. `docs/00_policy_tdc114plus_development_2026-07-02.md` 개정
2. `docs/00_contract_tdc114plus_api_2026-07-02.md` 개정
3. `docs/00_policy_tdc114plus_testing_2026-07-02.md` 개정
이 세 문서가 먼저 정리되면, 이후 코드 구조 개편도 문서 기준으로 일관되게 진행할 수 있다.
@@ -0,0 +1,267 @@
# Android target 통합테스트 진행 시나리오
작성일: 2026-07-03
상태: v0.1 진행 중
목적: `tdc114plus` Flutter 앱의 실제 Baron SSO API 로그인, 직원목록 진입, 플랫폼 액션 수동 검증을 Android 실기기 우선, emulator fallback 기준으로 단계적으로 확인한다.
## 1. 적용 범위
이 시나리오는 아래 작업에 적용한다.
- Android 실기기 준비
- Android emulator fallback 준비
- Android 런타임에서 Baron SSO local API 접근 확인
- `app/integration_test/` + `app/test_driver/` 기반 Flutter integration test 실행
- 실제 기기 수동 smoke: 로그인, 직원목록, 전화/문자 버튼, 즐겨찾기 유지
Playwright MCP는 이 시나리오의 실행 대상이 아니다. Playwright MCP가 필요한 단계가 오면 테스트 정책에 따라 사용자 확인과 정책 문서 갱신을 먼저 진행한다.
## 2. 사전 원칙
- 전화번호와 token은 명령 출력, 문서, git tracked 파일에 남기지 않는다.
- `scripts/.env.smoke.local`, `scripts/.env.android-emulator.local`, `scripts/.env.android-device.local``.gitignore` 대상이다.
- 기본 테스트 타깃은 Android 실기기다.
- Android emulator에서는 host `127.0.0.1`이 아니라 보통 `10.0.2.2`로 host machine에 접근한다.
- Android 실기기는 `adb reverse` 또는 PC LAN IP 중 하나를 선택한다.
- Flutter 앱 변경 후에는 `./scripts/format-dart.sh`, `./scripts/quality-gate.sh`를 실행한다.
- 테스트 실행 결과는 `docs/test-logs/2026-07-test-execution-log.md`에 누적 기록한다.
## 3. 진행 체크리스트
| 단계 | 상태 | 목적 | 명령/확인 | 완료 기준 |
| --- | --- | --- | --- | --- |
| 0 | 완료 | 시나리오 문서 생성 | 본 문서 작성 | 문서가 `docs/`에 존재 |
| 1 | 완료 | Baron SSO local runtime 준비 확인 | `./scripts/check-baron-api-env.sh` | failure/warning 없이 통과 |
| 1-A | 완료 | `baron_backend` 미실행 시 복구 | Baron SSO API worktree에서 `docker compose up -d backend` | `baron_backend` running/healthy |
| 1-B | 완료 | Ory/UserFront runtime 확인 및 복구 | `docker ps -a`, 필요 시 Ory/UserFront 컨테이너 시작 | `ory_kratos`, `ory_hydra`, `ory_keto`, `ory_oathkeeper`, `ory_postgres`, `baron_userfront` running |
| 2 | 완료 | host 기준 authenticated API smoke 확인 | `./scripts/api-smoke.sh` | phone-login/직원목록/조직도 HTTP 200 |
| 3 | 완료 | Flutter/Docker가 인식하는 device 확인 | `./scripts/flutter-docker.sh devices` | Android target이 표시되거나 부재 원인 확인 |
| 3-A | 완료 | host/WSL ADB 상태 확인 | `adb devices` 또는 `which adb` | ADB 설치/연결 상태 확인 |
| 3-B | 완료 | Android target 준비 | Windows Android Studio emulator 실행 + WSL/Docker에서 ADB 접근 구성 | Flutter devices에 Android target 표시 |
| 4 | 대기 | 실기기용 API 주소 결정 | `scripts/.env.android-device.local` | 실기기에서 접근 가능한 `TDC114_API_BASE` 확정 |
| 4-A | 완료 | Android target Docker ADB 인식 확인 | `ADB_SERVER_SOCKET=tcp:172.21.128.1:5037 ./scripts/flutter-docker.sh devices` | 실기기 또는 fallback emulator 표시 |
| 5 | 완료 | emulator integration test 실행 | `TDC114_SMOKE_ENV_FILE=scripts/.env.android-emulator.local TDC114_ADB_CONNECT_ADDRESS=172.21.128.1:5555 ./scripts/integration_tests.sh` | `+3: All tests passed!` |
| 5-B | 완료 | 로그인 완료 가정 기능점검 | `TDC114_SMOKE_ASSUME_LOGGED_IN=1` 기반 세션 bootstrap 후 emulator integration test 실행 | 앱이 post-login 상태에서 직원검색/즐겨찾기/상세 액션 smoke를 통과 |
| 5-A | 완료 | Docker local ADB 방식 검증 | Windows `5555` portproxy + Docker 내부 `adb connect` | integration test runner가 VM service에 연결 |
| 6 | 대기 | 실기기 연결 확인 | `adb devices` 또는 Flutter devices | device 상태 표시 |
| 7 | 대기 | 실기기 API 접근 방식 결정 | `adb reverse tcp:5000 tcp:5000` 또는 PC LAN IP | 실기기 브라우저/API 접근 가능 |
| 8 | 대기 | 실기기 integration test 실행 | `TDC114_SMOKE_ENV_FILE=scripts/.env.android-device.local ./scripts/integration_tests.sh` | 로그인 후 직원목록 진입 |
| 9 | 대기 | 플랫폼 액션 수동 smoke | 실제 앱에서 전화/문자 버튼 확인 | 전화/문자 intent가 정상 호출 |
| 10 | 대기 | 즐겨찾기 유지 수동 smoke | 즐겨찾기 토글 후 앱 재실행 | 즐겨찾기 상태 유지 |
| 11 | 대기 | 결과 기록 | 테스트 로그 업데이트 | 성공/실패와 후속 조치 기록 |
## 4. 환경 파일 예시
### 4.1 Host smoke
기본 파일:
```text
scripts/.env.smoke.local
```
예시:
```bash
TDC114_API_BASE=http://127.0.0.1:5000
TDC114_SMOKE_PHONE=010xxxxxxxx
```
### 4.2 Android emulator fallback
기본 파일:
```text
scripts/.env.android-emulator.local
```
예시:
```bash
TDC114_API_BASE=http://10.0.2.2:5000
TDC114_SMOKE_PHONE=010xxxxxxxx
```
### 4.3 Android 실기기
`adb reverse`를 쓰는 경우:
```bash
TDC114_API_BASE=http://127.0.0.1:5000
TDC114_SMOKE_PHONE=010xxxxxxxx
```
PC LAN IP를 쓰는 경우:
```bash
TDC114_API_BASE=http://<PC_LAN_IP>:5000
TDC114_SMOKE_PHONE=010xxxxxxxx
```
## 5. 실패별 분기
| 증상 | 의미 | 다음 조치 |
| --- | --- | --- |
| `No supported devices connected.` | Flutter가 실행 가능한 Android target을 못 봄 | USB 디버깅 실기기 확인, 필요 시 Android Studio emulator fallback 실행, Docker/ADB 접근 방식 확인 |
| `Integration tests and unit tests cannot be run in a single invocation.` | 현재 Flutter 버전에서 Android integration을 `flutter test`로 호출함 | `flutter drive --driver=test_driver/integration_driver.dart --target=integration_test/...` 경로로 전환 |
| Linux desktop만 표시됨 | WSL/Docker Flutter는 보이지만 앱에 Linux runner가 없음 | Android target 연결 또는 별도 desktop runner 추가 검토 |
| `adb: command not found` | WSL에 Android platform-tools가 없음 | Windows Android Studio platform-tools를 PATH로 연결하거나 WSL에 Android SDK platform-tools 설치 |
| Windows `adb -a -P 5037 nodaemon server`에서 10048 | 기존 ADB server 또는 다른 프로세스가 5037 포트를 이미 사용 중 | `Get-Process adb`/`Stop-Process`, `netstat -ano | findstr :5037`, `taskkill /PID ... /F` 후 재시도 |
| `WebSocketChannelException`, `127.0.0.1:<dynamic port>` connection refused | remote Windows ADB server가 만든 VM service forward가 Docker container localhost와 맞지 않음 | Windows emulator adbd `5555`를 portproxy로 노출하고 Docker 내부 local ADB server에서 `adb connect` 방식 검증 |
| Docker local ADB가 `unauthorized` | Docker container의 ADB key가 emulator에서 승인된 Windows ADB key와 다름 | Windows 사용자 ADB key를 git ignored `.android-adb/`에 복사하고 Docker `/root/.android`로 마운트 |
| integration test 로그인 화면/validation은 통과하지만 real API login test 실패 | 앱/API 문제 또는 backend runtime 중단 가능 | 먼저 `./scripts/api-smoke.sh`로 host authenticated smoke 재확인, 502면 Baron/Ory runtime 복구 |
| API smoke는 통과하지만 integration test login 실패 | Android 런타임에서 API base URL 접근 실패 가능 | emulator는 `10.0.2.2`, 실기기는 `adb reverse` 또는 PC LAN IP 확인 |
| HTTP cleartext 차단 | Android 앱이 `http://` 접근을 막을 수 있음 | Android network security config 또는 manifest 확인 |
| phone-login 401 | 테스트 전화번호가 Baron SSO 등록자와 불일치 | env 파일의 전화번호와 Baron SSO 등록 상태 확인 |
| phone-login 503 | Baron SSO/Ory/Kratos 세션 발급 문제 | backend 로그 확인, 인증 민감 영역으로 별도 검토 |
| backend는 running인데 `kratos`, `keto`, `hydra` DNS 실패 | Ory runtime 컨테이너가 중지됨 | Ory/UserFront 컨테이너 시작 후 runtime 점검 재실행 |
| employee/orgchart 401/403 | token/session/권한 문제 | 앱 세션 저장/전달, backend auth middleware 확인 |
## 6. 현재 실행 메모
- 2026-07-03: 문서 생성. 다음 단계는 1단계 `check-baron-api-env.sh` 실행이다.
- 2026-07-03: 1단계 실행 결과 `baron_backend`가 running 상태가 아니어서 1-A 복구 단계를 추가했다.
- 2026-07-03: 1-A에서 `docker compose up -d backend` 실행 후 1단계 재검증 통과. `baron_backend`, `baron_gateway` running 확인.
- 2026-07-03: 2단계 API smoke에서 phone-login 503 발생. backend 로그에서 `kratos`, `keto`, `hydra`, `oathkeeper` DNS 실패 확인. 1-B Ory/UserFront 복구 단계를 추가했다.
- 2026-07-03: 1-B에서 Ory/UserFront 컨테이너를 시작했다. `baron_backend`, `baron_userfront`, `baron_gateway`, Ory 핵심 컨테이너 running 확인.
- 2026-07-03: 2단계 API smoke 재실행 통과. phone-login, employee list, tenant list, orgchart 모두 HTTP 200.
- 2026-07-03: 3단계 `./scripts/flutter-docker.sh devices` 실행 결과 Docker Flutter는 `Linux desktop`만 인식하고 Android emulator/device는 인식하지 못했다. 3-A ADB 상태 확인 단계를 추가했다.
- 2026-07-03 08:11 KST: 3-A 확인 결과 WSL에 `adb`가 설치되어 있지 않다. `./scripts/integration_tests.sh`는 Linux desktop만 발견했지만 앱에 Linux runner가 없어 `No supported devices connected.`로 종료했다. 3-B Android target 준비 단계가 필요하다.
- 2026-07-03 오전: Windows Android Studio에서 `Medium Phone` Android 15 API 35 emulator를 생성하고 Windows `adb.exe devices`에서 `emulator-5554 device`를 확인했다.
- 2026-07-03 오전: Windows `adb -a -P 5037 nodaemon server`는 기존 ADB server 자동 재기동으로 `10048` 오류가 반복되어 `netsh interface portproxy` 방식으로 전환했다.
- 2026-07-03 오전: 관리자 PowerShell에서 `0.0.0.0:5037 -> 127.0.0.1:5037` portproxy와 방화벽 rule을 추가했다. WSL에서 `172.21.128.1:5037` 연결 성공, Docker Flutter 내부 `adb devices`에서 `emulator-5554 device` 확인.
- 2026-07-03 10:22 KST: `scripts/integration_tests.sh`를 실시간 출력 방식으로 개선한 뒤 emulator integration test를 재실행했다. APK 빌드/설치는 성공했지만 test loading 단계에서 `WebSocketChannelException`, `127.0.0.1:<dynamic port>` connection refused로 실패했다. remote Windows ADB server의 VM service port forward가 Docker container localhost와 맞지 않는 구조로 판단하고 5-A를 추가했다.
- 2026-07-03 11:06 KST: Windows 관리자 PowerShell에서 `0.0.0.0:5555 -> 127.0.0.1:5555` portproxy와 방화벽 rule을 추가했다. Docker local ADB 방식에서 최초 `unauthorized`가 발생했으나 Windows 사용자 ADB key를 git ignored `.android-adb/`에 복사하고 Docker `/root/.android`로 마운트해 `172.21.128.1:5555 device` 상태를 확보했다.
- 2026-07-03 11:06 KST: Docker local ADB 방식으로 integration test를 재실행했다. 최초 real API login test 실패는 Baron/Ory runtime 중단에 따른 `api-smoke.sh` 502와 연관됨을 확인했고, Ory/UserFront 및 backend 재시작 후 authenticated API smoke 통과, 최종 integration test `+3: All tests passed!`를 확인했다.
- 2026-07-06: 신규 링크 로그인 전환 이후에는 local 승인 완료 E2E가 막힐 수 있으므로, Android target 기능점검은 `TDC114_SMOKE_ASSUME_LOGGED_IN=1`을 이용한 post-login 세션 seed 경로를 함께 사용한다.
- 2026-07-06: local Baron SSO runtime에 테스트 번호 `010-9136-5338`용 identity와 local user를 맞춘 뒤 legacy `phone-login` HTTP 200, headless 로그인 시작 응답, `link/poll` pending 상태까지 확인했다.
- 2026-07-06: 로그인 완료 가정 기능점검 재시도에서는 host `api-smoke.sh`는 다시 통과했지만, `172.21.128.1:5555``offline`, `172.21.128.1:5037` 경로는 `protocol fault`라 Android target 연결이 실패했다. 현재 blocker는 앱/API가 아니라 Windows emulator 또는 ADB/portproxy 상태다.
## 7. 3-B Android target 준비 상세 절차
아래 중 하나를 선택한다.
### 7.0 현재 WSL 확인 결과
2026-07-03 현재 확인한 내용:
- WSL 내부 `adb` 명령은 없다.
- WSL 내부 `java` 명령은 없다.
- WSL 내부 `sdkmanager`, `emulator` 명령은 없다.
- `ANDROID_HOME`, `ANDROID_SDK_ROOT` 환경 변수는 설정되어 있지 않다.
- `/dev/kvm` 장치가 확인되지 않아 WSL 내부 Android emulator 가속 실행 가능성이 낮다.
- 일반적인 Windows Android SDK 경로(`/mnt/c/Users/*/AppData/Local/Android/Sdk/platform-tools`)가 WSL에서 바로 발견되지 않았다.
- WSL에서 `powershell.exe`를 호출해 Windows 경로를 자동 탐색하려 했으나 현재 세션에서는 `UtilBindVsockAnyPort` 오류로 실행되지 않았다.
따라서 캡쳐처럼 Ubuntu/WSL 내부에 Android SDK, emulator, AVD를 직접 설치하는 경로는 현재 즉시 진행 가능하지 않다. 진행하려면 Java, Android command-line tools, platform-tools, emulator, system image, KVM/GUI 조건을 새로 맞춰야 한다. 더 안정적인 다음 단계는 Windows에서 Android Studio/SDK 설치 여부와 `adb.exe` 위치를 직접 확인하는 것이다.
### 7.0-A Ubuntu/WSL 내부 emulator 경로 판단
캡쳐의 Ubuntu 설치 방식은 아래 조건이 모두 충족될 때만 추천한다.
1. WSL에서 `/dev/kvm`이 보인다.
2. WSLg 또는 별도 X server로 emulator GUI 표시가 가능하다.
3. Java 11 이상과 Android command-line tools 설치가 가능하다.
4. `sdkmanager`, `avdmanager`, `emulator`, `adb`가 WSL 내부 PATH에 잡힌다.
5. Docker Flutter 컨테이너에서도 해당 emulator/ADB에 접근할 수 있다.
현재 환경은 1, 3, 4가 충족되지 않는다. 그래서 우선순위는 `Windows Android Studio emulator 또는 실기기 + WSL/ADB 연결` 방식으로 둔다.
### 7.1 Android emulator 사용
1. Windows Android Studio를 연다.
2. Device Manager에서 Pixel 계열 emulator를 생성하거나 기존 emulator를 시작한다.
3. Windows 터미널에서 `adb devices`로 emulator가 보이는지 확인한다.
4. WSL에서 Android platform-tools를 사용할 수 있게 한다.
- 방법 A: Windows Android SDK `platform-tools` 경로를 WSL `PATH`에 연결한다.
- 방법 B: WSL에 Android SDK platform-tools를 별도로 설치한다.
5. WSL에서 `adb devices`가 emulator를 표시하는지 확인한다.
6. Docker Flutter가 Android device를 볼 수 있도록 `flutter-docker.sh`의 ADB socket/device mount 필요 여부를 확인한다.
### 7.2 Android 실기기 사용
1. 휴대폰에서 개발자 옵션을 활성화한다.
2. USB 디버깅을 켠다.
3. USB로 PC에 연결하고 RSA 디버깅 허용 팝업을 승인한다.
4. Windows 또는 WSL에서 `adb devices``device` 상태로 표시되는지 확인한다.
5. 실기기에서 local API 접근은 `adb reverse tcp:5000 tcp:5000` 또는 PC LAN IP 방식 중 하나를 선택한다.
### 7.3 Windows emulator + Docker local ADB server 방식
`ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037` 방식은 Flutter device 목록 확인과 APK 설치까지 가능했지만, integration test loading 단계에서 VM service dynamic port가 Docker container의 `127.0.0.1`로 연결되지 않는 문제가 발생했다.
다음 검증은 Docker container 내부의 local ADB server가 Windows emulator adbd에 직접 붙는 방식으로 진행한다.
1. Windows 관리자 PowerShell에서 emulator adbd port를 노출한다.
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=5555 connectaddress=127.0.0.1 connectport=5555
netsh advfirewall firewall add rule name="ADB emulator 5555 for WSL" dir=in action=allow protocol=TCP localport=5555
netsh interface portproxy show v4tov4
```
2. WSL에서 `5555` 연결을 확인한다.
```bash
nc -vz 172.21.128.1 5555
```
3. Docker Flutter container 내부에서 local ADB server로 emulator에 직접 연결한다.
```bash
docker run --rm ghcr.io/cirruslabs/flutter:stable sh -lc 'adb connect 172.21.128.1:5555 && adb devices'
```
4. 이 방식에서 device가 표시되면 `scripts/flutter-docker.sh``TDC114_ADB_CONNECT_ADDRESS` 같은 optional env를 추가해 테스트 실행 전 `adb connect`를 수행하도록 보강한다.
5. Docker ADB가 `unauthorized`로 표시되면 Windows에서 이미 승인된 ADB key를 local ignored directory에 복사한다.
```bash
mkdir -p .android-adb
cp /mnt/c/Users/user/.android/adbkey .android-adb/adbkey.new
cp /mnt/c/Users/user/.android/adbkey.pub .android-adb/adbkey.pub.new
mv -f .android-adb/adbkey.new .android-adb/adbkey
mv -f .android-adb/adbkey.pub.new .android-adb/adbkey.pub
chmod 600 .android-adb/adbkey
```
6. 최종 실행 명령:
```bash
TDC114_SMOKE_ENV_FILE=scripts/.env.android-emulator.local \
TDC114_ADB_CONNECT_ADDRESS=172.21.128.1:5555 \
./scripts/integration_tests.sh
```
현재 환경의 통과 기준 출력:
```text
+3: All tests passed!
```
### 7.4 로그인 완료 가정 기능점검
신규 링크 로그인 흐름에서는 local runtime에서 실제 승인 완료까지 항상 검증할 수 있는 것이 아니므로, Android target 기능점검은 아래 보조 경로를 사용한다.
1. 기본은 mock 세션 seed를 사용한다.
2. legacy 호환 점검이 꼭 필요할 때만 host 기준 `phone-login` API로 유효 세션을 1회 발급받는다.
3. 해당 세션 또는 mock user 정보를 Flutter app 시작 전에 `SharedPreferences`에 seed 한다.
4. 앱이 `AuthGate`에서 로그인 화면 대신 `직원검색`으로 바로 진입하는지 확인한다.
5. 직원 목록, 즐겨찾기, 직원 상세, 전화/문자 버튼 노출을 integration test로 점검한다.
6. local runtime이 유효 세션을 발급하지 못하면 mock directory 모드로 fallback 하여 post-login UI 기능만 별도로 점검한다.
실행 예시:
```bash
TDC114_SMOKE_ENV_FILE=scripts/.env.android-emulator.local \
TDC114_SMOKE_ASSUME_LOGGED_IN=1 \
TDC114_ADB_CONNECT_ADDRESS=172.21.128.1:5555 \
./scripts/integration_tests.sh
```
주의:
- 이 경로는 "로그인 완료 이후 앱 기능" 점검용이다.
- 실제 `headless phone-login -> 승인 -> link/poll 성공` 자체를 대체하지는 않는다.
- local `phone-login` bootstrap이 `401 login_failed`이면 자동으로 mock directory fallback을 사용한다.
@@ -0,0 +1,354 @@
# staging Baron SSO 승인 로그인 검증 시나리오
작성일: 2026-07-06
상태: v0.2 진행 중
목적: `tdc114plus` 신규 전화번호 승인 로그인 흐름을 local Baron SSO runtime 대신 staging Baron SSO 환경에서 우선 검증하기 위한 절차를 정리한다. 공식 기준은 `Baron SSO Hosted Login + OIDC Authorization Code + PKCE`이며, headless 직접 호출은 레거시/참고 검증으로만 본다.
## 1. 왜 staging 경로를 우선 쓰는가
- 현재 local Baron SSO runtime에는 테스트 대상 전화번호의 identity/user mirror가 없을 수 있다.
- 이 경우 local에서는 `Hosted Login -> 문자/메일 링크 인증 -> App Link callback -> PKCE token 교환`을 끝까지 확인하기 어렵다.
- staging Baron SSO에 테스트 번호, RP 등록, redirect URI, 실제 링크 발송 경로가 준비되어 있다면 실제 승인 완료 end-to-end 검증을 더 빠르게 진행할 수 있다.
## 2. 적용 범위
이 시나리오는 아래 검증에 적용한다.
- staging Baron API smoke
- Android target 기준 staging API integration test
- 실제 승인 완료 로그인 1회 검증
- staging 반영 검토 전환 판단
## 3. 필요한 값
아래 값이 준비되어야 한다.
- `TDC114_API_BASE`
- staging Baron API base URL
- `TDC114_SMOKE_PHONE`
- staging Baron SSO에 등록된 테스트 전화번호
- 선택값 `TDC114_SMOKE_EXPECTED_NAME`
- 로그인 후 화면에서 확인할 사용자명
- 선택값 `TDC114_SMOKE_EXPECTED_TENANT_LABEL`
- 로그인 후 화면에서 확인할 조직 또는 테넌트 라벨
권장 env 파일:
```text
scripts/.env.staging.local
```
기본 예시는 아래 파일을 복사해서 사용한다.
```text
scripts/smoke.staging.env.example
```
## 4. 선행 조건
아래 조건이 먼저 만족되어야 한다.
- Flutter auth/model/widget test 통과
- backend handler/server test 통과
- staging 대상 Baron SSO Hosted Login, authorization endpoint, token endpoint가 사용 가능함
- 테스트 번호 수신 단말에서 링크 승인 가능
- 테스트 결과를 `docs/test-logs/2026-07-test-execution-log.md`에 기록할 준비가 되어 있음
배포 전 내부 준비 기준은 아래 순서를 따른다.
1. 로컬 구현 완료 고정
2. 자동화 테스트 고정
3. 로컬 API 계약 검증
4. Android target UI 기능 확인
5. Android target 실사용 흐름 점검
6. 수동 점검 체크리스트 정리
7. staging 반영 직전 검토
## 5. 권장 진행 순서
### 5.1 1단계: env 준비
예시:
```bash
cp scripts/smoke.staging.env.example scripts/.env.staging.local
```
값을 채운다.
```bash
TDC114_API_BASE=https://staging.example.com
TDC114_SMOKE_PHONE=010xxxxxxxx
TDC114_SMOKE_AUTH_FLOW=link
TDC114_SMOKE_EXPECTED_NAME=홍길동
TDC114_SMOKE_EXPECTED_TENANT_LABEL=IS3
```
사용자명과 조직 라벨은 optional이다. 값이 자주 바뀌거나 확실하지 않으면 비워둔다.
### 5.2 2단계: staging API smoke
먼저 host 기준 smoke를 확인한다.
```bash
TDC114_SMOKE_ENV_FILE=scripts/.env.staging.local ./scripts/api-smoke.sh
```
확인 포인트:
- Hosted Login authorization URL 생성 가능
- RP `client_id`, `redirect_uri`, PKCE `code_challenge` 조합이 Baron SSO에서 거부되지 않음
- App Link callback과 token 교환에 필요한 endpoint가 확인됨
- 레거시 headless smoke는 필요 시 참고로만 수행
주의:
- staging의 공식 성공 기준은 앱 직접 headless 호출이 아니라 Hosted Login 진입과 PKCE callback 완료다.
- 실제 승인 완료는 integration test 또는 수동 검증으로 이어서 확인한다.
### 5.3 3단계: Android target 준비
기존 Android runtime 절차를 따른다.
- emulator는 `docs/scenario_android_emulator_device_integration_test_2026-07-03.md`
- ADB 정책은 `docs/policy_android_studio_wsl_adb_2026-07-03.md`
핵심은 `TDC114_API_BASE`를 staging 값으로 바꾸고, 독립형 실기기 테스트에서는 `TDC114_SKIP_SESSION_BOOTSTRAP=true`로 로컬 legacy bootstrap을 끄는 것이다.
공기계 독립 실행 권장 env:
```text
scripts/.env.android-device.staging.local
```
예시:
```text
TDC114_API_BASE=https://staging.example.com
TDC114_SKIP_SESSION_BOOTSTRAP=true
TDC114_SMOKE_AUTH_FLOW=link
TDC114_SMOKE_PHONE=010xxxxxxxx
```
로그인만 staging, 직원/조직 데이터만 production으로 분리해야 하면 아래 override를 추가한다.
```text
TDC114_AUTH_API_BASE=https://staging.example.com
TDC114_DIRECTORY_API_BASE=https://production.example.com
TDC114_ORGANIZATION_API_BASE=https://production.example.com
```
### 5.4 4단계: staging integration test 실행
emulator fallback 예시:
```bash
TDC114_SMOKE_ENV_FILE=scripts/.env.staging.local \
TDC114_ADB_CONNECT_ADDRESS=172.21.128.1:5555 \
./scripts/integration_tests.sh
```
이미 승인된 prefix 경로를 쓰는 경우에는 기존 Android emulator 실행 방식과 동일하게 적용한다.
integration test의 현재 검증 기준:
- 로그인 화면 표시
- 빈 전화번호 validation
- `TDC114_SMOKE_PHONE`가 있으면 실제 번호 입력 후 로그인 시도
- 로그인 성공 시 `직원검색`, `검색 결과` 확인
- optional expected 값이 있으면 사용자명/조직 라벨까지 추가 확인
### 5.4-A 4-A단계: 공기계 독립 로그인 실행
공기계 또는 실사용 폰에 staging 직접 로그인 화면을 띄우려면 아래 경로를 사용한다.
```bash
ADB_SERVER_SOCKET=tcp:<WINDOWS_HOST_IP>:5037 \
TDC114_SMOKE_ENV_FILE=scripts/.env.android-device.staging.local \
TDC114_FLUTTER_DEVICE_ID=<PHYSICAL_DEVICE_ID> \
./scripts/manual-postlogin-run.sh
```
이 모드에서는:
- 앱이 local legacy `phone-login` bootstrap을 시도하지 않는다.
- 앱이 staging Baron API를 직접 사용한다.
- 로그인은 앱의 `Baron SSO로 로그인` 버튼으로 Hosted Login을 열고, 인증 완료 후 App Link callback과 PKCE token 교환으로 완료한다.
### 5.5 5단계: 실제 승인 완료 수동 확인
테스트 단말에서 아래를 수행한다.
1. 앱에서 `Baron SSO로 로그인`
2. 열린 Baron SSO Hosted Login 화면에서 휴대폰번호 입력
3. 수신된 문자 또는 메일의 링크 열기
4. `https://114.hmac.kr/auth/callback` App Link로 앱 복귀 확인
5. PKCE token 교환 후 `직원검색` 진입 확인
성공 기준:
- Baron SSO authorization endpoint 진입 성공
- 사용자가 수신 링크를 열 수 있음
- App Link callback 수신 및 `state` 검증 성공
- 앱 세션 저장 후 `직원검색` 화면 진입
## 5-A. staging 배포 전 수동 점검 체크리스트
아래 항목은 staging 반영 요청 전에 미리 채워두는 것을 권장한다.
### 5-A.1 에뮬레이터 기준 확인
- 로그인 화면이 정상 표시되는가
- 빈 전화번호 validation이 정상 동작하는가
- `Baron SSO로 로그인` 버튼이 Hosted Login 화면을 여는가
- 인증 대기 중 문구와 로딩 상태가 정상 표시되는가
- 오류 발생 시 내부 시스템 정보 없이 일반화된 안내 문구가 보이는가
- 로그인 성공 후 `직원검색` 첫 화면으로 진입하는가
- 첫 화면에서 기본 검색 결과가 표시되는가
- 직원 상세, 즐겨찾기, 전화/문자 버튼이 기존처럼 동작하는가
### 5-A.2 예외 시나리오 확인
- 미등록 번호 입력 시 generic 실패 안내가 보이는가
- 레거시 headless fallback을 켠 경우에만 만료된 `pendingRef`/poll 종료 처리가 자연스러운가
- poll 간격이 짧을 때 제한 응답 또는 재시도 대기 처리가 되는가
- 앱 재실행 후 세션 복원이 되는가
- 인증 만료 시 세션 정리 후 재로그인 흐름으로 복귀하는가
### 5-A.3 staging 반영 직전 확인
- 정확한 staging `TDC114_API_BASE`를 확보했는가
- 해당 base에서 Hosted Login authorization endpoint와 token endpoint를 사용할 수 있는가
- 테스트 번호로 문자 또는 메일 수신이 가능한가
- staging 로그 확인 담당자와 확인 위치를 알고 있는가
- 실패 시 되돌릴 범위와 회귀 확인 방법을 문서로 정리했는가
## 6. 예외 케이스 점검
실제 승인 완료 1회가 끝나면 아래를 추가 확인한다.
- 미등록 번호 입력 시 generic 실패 메시지
- 레거시 headless fallback을 켠 경우에만 만료된 `pendingRef` 처리
- polling 간격이 너무 짧을 때 `slow_down` 또는 유사 제한 동작
- 로그인 성공 후 directory API 401 없이 첫 화면 진입 가능한지
## 7. 성공 시 다음 판단
아래가 충족되면 staging 반영 검토 시작 가능 단계로 본다.
- 실제 승인 완료 end-to-end 1회 이상 통과
- 예외 케이스 점검 완료
- 테스트 번호, 로그 확인 포인트, 롤백 경로 정리
## 8. 현재 메모
- 2026-07-06 현재 local runtime에는 테스트 대상 전화번호 identity/user mirror가 없어 local 승인 완료 검증이 막혀 있다.
- 따라서 다음 실질 작업은 staging 번호와 staging API base를 확보해 위 절차로 1회 end-to-end를 끝까지 확인하는 것이다.
## 9. 팀장 공유용 문서 버전
이번 신규앱 로그인은 기존의 선인증 전화번호 로그인과 다르게, `전화번호 입력 -> 링크 발송 -> 사용자 승인 -> 승인 상태 polling -> 세션 저장`의 비동기 승인 절차를 거친다. 따라서 단순히 앱 소스 구현만 완료되었다고 해서 staging 반영 가능 상태라고 보기는 어렵고, staging Baron SSO 쪽에도 신규 로그인 흐름을 실제로 수용할 준비가 필요하다.
기존 로그인은 Baron SSO가 전화번호 확인 후 바로 세션을 발급하는 구조여서, 로컬 또는 기존 테스트 계정만으로도 비교적 빠르게 검증이 가능했다. 반면 이번 로그인은 사용자의 승인 링크 발송과 승인 완료 상태 변경이 중간에 개입하므로, staging 환경에서는 앱 코드 외에도 API 라우트 배포, 테스트 사용자 데이터, 링크 발송 경로, 승인 후 상태 조회가 모두 함께 맞아야 실제 end-to-end 검증이 가능하다.
따라서 이번 건은 "앱 구현 완료 후 즉시 staging 반영"이 아니라, "앱 구현과 backend 구현을 고정한 뒤 staging 준비 항목을 맞추고, 실제 승인 완료를 1회 이상 확인한 후 반영 검토" 순서로 접근하는 것이 안전하다.
## 10. staging 단계에서 준비가 필요한 항목
아래 항목은 로컬 준비가 아니라 staging 환경에서 선행되어야 하는 준비 사항이다.
### 10.1 staging 신규앱 API 배포 필요 여부
현재 기준으로는 필요 가능성이 매우 높다.
- 신규앱은 legacy 즉시 로그인이나 앱 직접 headless 호출보다 Baron SSO Hosted Login + PKCE 계약을 기본으로 사용한다.
- 따라서 staging 대상 Baron SSO/RP 설정에 아래 OIDC endpoint와 설정이 실제 사용 가능해야 한다.
- Authorization endpoint
- Token endpoint
- TDC114PLUS RP `client_id`
- `https://114.hmac.kr/auth/callback` redirect URI
- 추가로 로그인 이후 첫 화면 진입까지 확인하려면 아래 데이터 API도 확인되어야 한다.
- `GET /api/v1/integrations/org-context`
- 필요 시 `GET /api/v1/public/orgchart`
정리:
- staging에 TDC114PLUS RP 설정과 Hosted Login 흐름이 아직 반영되지 않았다면, 실제 승인 로그인 검증 전에 RP 등록/설정 반영이 선행되어야 한다.
- 단순 Baron SSO 대표 URL만 알고 있는 상태로는 부족하고, 실제 OIDC discovery/authorization/token endpoint와 redirect URI 허용 여부가 필요하다.
### 10.2 staging API base URL 확정
아래가 명확해야 한다.
- 신규앱이 호출해야 하는 정확한 staging `TDC114_API_BASE`
- 해당 base가 실제로 Swagger 공개 route를 제공하는지
- gateway, reverse proxy, auth middleware가 신규 route를 정상 통과시키는지
현재 확인상 `https://sso.hmac.kr`는 Baron SSO 대표 주소일 수는 있으나, 앱 데이터 API base로 확정할 근거는 부족했다.
### 10.3 staging 테스트 사용자 준비
staging Baron SSO에는 아래 조건을 만족하는 테스트 사용자가 준비되어야 한다.
- 테스트 전화번호가 staging Baron SSO 인증 대상 사용자로 존재
- 해당 사용자가 링크 발송 대상 lookup에서 조회 가능
- 승인 완료 후 앱 세션 발급 대상 사용자로 연결 가능
- 직원검색/조직도 API에서 앱 사용자로도 정상 조회 가능
쉽게 말하면 staging에서는 "전화번호를 아는 인증 사용자"와 "앱이 직원으로 인식하는 사용자 정보"가 둘 다 준비되어 있어야 한다.
### 10.4 staging 링크 발송 경로 준비
이번 로그인은 사용자가 실제 링크를 수신해야 하므로 아래가 staging에서 준비되어야 한다.
- 문자 또는 메일 발송 provider가 staging에서 활성화되어 있는지
- 테스트 번호에서 실제 링크를 수신 가능한지
- 링크 클릭 후 staging Baron SSO가 인증 완료 상태로 전환되고 redirect URI로 authorization code를 돌려주는지
이 항목이 안 맞으면 Hosted Login 진입은 성공해도 실제 승인 완료 검증은 끝까지 진행되지 않는다.
### 10.5 staging callback/token 교환 경로 준비
신규앱은 승인 후 App Link callback으로 authorization code를 받고, token endpoint에서 PKCE token 교환을 수행한다. 따라서 staging에서는 아래가 준비되어야 한다.
- `https://114.hmac.kr/auth/callback` redirect URI 허용
- callback에 `code`, `state` 전달
- token endpoint에서 `authorization_code + code_verifier` 교환 허용
- Client Secret 없이 PKCE 공개 앱으로 token 교환 가능
이 부분은 단순 route 존재 여부만으로는 부족하고, 실제 링크 승인과 callback/token 교환이 연결되어 있어야 한다.
### 10.6 staging 로그 확인 포인트 확보
실제 승인 로그인 검증 중에는 실패 원인 분리가 중요하므로 아래를 알아야 한다.
- staging backend 로그 확인 위치
- 필요 시 gateway 또는 auth 로그 확인 위치
- Hosted Login 진입, 링크 발송, callback redirect, token 교환 실패 로그를 누가 확인할 수 있는지
이 정보가 없으면 staging에서 실패가 나도 앱 문제인지, API 미배포인지, 사용자 데이터 문제인지 구분이 늦어진다.
### 10.7 staging 반영 실패 대비 경로
아래도 staging 단계에서 미리 정리되어야 한다.
- 신규앱 API가 미반영 상태로 남을 경우의 원복 또는 비활성 경로
- 기존 로그인 방식과 신규 로그인 방식의 분리 여부
- 문제가 생겼을 때 local 회귀 검증을 어떤 기준으로 다시 확인할지
이번 변경은 신규 로그인 플로우가 추가된 형태이므로, staging에서도 route 단위로 반영 범위와 되돌림 범위를 설명할 수 있어야 한다.
## 11. staging 준비 완료 판단 기준
아래가 충족되어야 staging에서 실제 승인 로그인 검증을 진행할 수 있다고 본다.
1. staging `TDC114_API_BASE`가 확정되었다.
2. staging Baron SSO에 TDC114PLUS RP와 Hosted Login + PKCE 설정이 반영되었다.
3. staging Baron SSO에 테스트 전화번호 사용자가 준비되었다.
4. 테스트 번호에서 실제 링크 수신이 가능하다.
5. 승인 후 App Link callback과 PKCE token 교환이 session 발급까지 이어진다.
6. 로그인 후 `직원검색` 첫 화면 진입이 가능하다.
7. 실패 시 확인할 로그 위치와 담당자 경로가 정리되었다.
위 기준이 충족되기 전에는 "staging 반영 가능"이라고 단정하지 않고, "staging 반영 검토 준비 단계"로 표현하는 것이 정확하다.
-436
View File
@@ -1,436 +0,0 @@
# tdc114plus API 계약
작성일: 2026-07-02
상태: v1.0 1차 구현 기준 확정
목적: `tdc114plus` Flutter 앱이 Baron SSO backend 및 orgFront 데이터와 연동하기 위해 필요한 API 계약을 정의한다. 본 문서는 구현 전 계약 기준이며, 실제 Baron SSO backend 구현은 `/home/ubuntu/workspace/baron-sso-tdc114plus-api``feature/tdc114plus-api` 브랜치에서 진행한다.
## 1. 설계 기준
확인한 기준 문서:
- `docs/tdc114plus-development-decision-brief-2026-07-01.md`
- `docs/tdc114plus-development-policy-2026-07-02.md`
- `docs/baron-sso-reference-source-policy-2026-07-02.md`
- `docs/references/baron-safe-policies/api-contract.md`
- Baron SSO `docs/API_DESIGN_POLICY.md`
- Baron SSO `docs/identity-redis-mirror-policy-2026-06-09.md`
- Baron SSO `docs/references/baron-safe-policies/nonstandard-phone-only-login-technical-review-2026-06-23.md`
확인한 기존 Baron SSO 코드:
- `backend/cmd/server/main.go`
- `backend/internal/handler/auth_handler.go`
- `backend/internal/handler/tenant_handler.go`
- `backend/internal/handler/user_handler.go`
- `backend/internal/domain/user.go`
- `backend/internal/domain/tenant.go`
- `orgfront/src/lib/adminApi.ts`
## 2. 핵심 원칙
- 기존 Baron SSO API 응답을 `tdc114plus` 요구사항에 맞게 직접 변경하지 않는다.
- `tdc114plus` 전용 endpoint와 response DTO를 둔다.
- JSON field는 camelCase를 사용한다.
- 목록 응답은 `items`, `limit`, `offset`, `total`, `nextCursor` 형식을 따른다.
- 직원/조직 데이터는 Baron SSO `orgFront` 및 backend read model을 기준으로 제공한다.
- 앱 자체 회원가입은 제공하지 않는다.
- 미등록 사용자는 앱 사용을 허용하지 않는다.
- 공지사항, 전자결재, 수신전화식별, 수신팝업은 1차 API 범위에서 제외한다.
## 3. 1차 구현 확정사항
| 항목 | 1차 구현 기준 | 후속 검토 |
| --- | --- | --- |
| 전화번호 로그인 보안 수준 | Baron SSO 등록 사용자 확인, rate limit, 감사 로그, generic error message 적용 | SMS OTP, 기기 등록, 내부망 제한, 추가 인증 |
| token 종류 | 기존 Baron SSO session token 재사용 | 앱 전용 access token 또는 refresh token 분리 |
| 개인정보 마스킹 범위 | Baron SSO 등록 사용자에게 직원 검색/조직도 기본 필드 노출 | 권한별 전화번호/이메일/직급 마스킹 정책 |
| 즐겨찾기 동기화 | 1차는 앱 로컬 저장 | 서버 동기화 API 추가 |
본 확정사항은 1차 개발 범위를 빠르게 구현하기 위한 기준이다. 운영 배포 전 보안 리뷰에서 전화번호 로그인, token 저장, 개인정보 노출 범위는 재검토한다.
## 4. API namespace
권장 namespace:
```text
/api/v1/tdc114plus
```
이유:
- 기존 `/api/v1/admin/users`, `/api/v1/admin/orgchart/snapshot`은 관리자/orgFront 성격이 강하다.
- 앱은 일반 등록 사용자용 직원검색/조직도 기능이므로 별도 namespace가 필요하다.
- 향후 감사 로그, 마스킹, 앱별 권한 정책을 독립적으로 적용하기 쉽다.
## 5. 인증 API
### 5.1 전화번호 로그인
```http
POST /api/v1/tdc114plus/auth/phone-login
```
설명:
- 사용자가 앱 로그인창에 전화번호를 입력하면 Baron SSO 등록 사용자 여부를 확인한다.
- 등록 사용자이면 앱 사용에 필요한 Baron SSO session token을 반환한다.
- 기존 Baron SSO의 `/api/v1/auth/phone-login` 흐름을 참고하되, `tdc114plus` 전용 DTO와 오류 정책을 둔다.
요청:
```json
{
"phoneNumber": "01012345678",
"device": {
"platform": "android",
"appVersion": "0.1.0",
"deviceName": "Pixel 8"
}
}
```
응답:
```json
{
"status": "ok",
"token": "baron-sso-session-token",
"expiresAt": "2026-07-02T12:00:00Z",
"user": {
"id": "user-uuid",
"name": "홍길동",
"phoneNumber": "+821012345678",
"tenantId": "tenant-uuid",
"tenantName": "한맥",
"tenantSlug": "hanmac",
"department": "기술연구소",
"grade": "책임",
"position": "팀장",
"jobTitle": "개발"
}
}
```
오류:
| HTTP | code | 설명 |
| --- | --- | --- |
| 400 | `invalid_phone_number` | 전화번호 형식 오류 |
| 401 | `login_failed` | 등록 사용자 확인 실패. 사용자 존재 여부를 과도하게 드러내지 않는다. |
| 429 | `rate_limited` | 반복 시도 제한 |
| 503 | `identity_provider_unavailable` | Kratos/SSO 조회 실패 |
보안 메모:
- 전화번호 단독 로그인은 표준 인증으로 보기 어렵다.
- 1차 정책상 Baron SSO 등록 인원 확인용으로 사용하되, rate limit, 감사 로그, generic error message를 적용한다.
- 운영 전에는 SMS OTP, 기기 등록, 내부망 제한, 추가 인증 중 하나 이상을 후속 검토한다.
### 5.2 내 프로필
```http
GET /api/v1/tdc114plus/me
Authorization: Bearer {token}
```
응답:
```json
{
"id": "user-uuid",
"name": "홍길동",
"phoneNumber": "+821012345678",
"email": "user@example.com",
"tenantId": "tenant-uuid",
"tenantName": "한맥",
"tenantSlug": "hanmac",
"department": "기술연구소",
"grade": "책임",
"position": "팀장",
"jobTitle": "개발",
"permissions": {
"directory": true,
"organization": true,
"favoritesSync": false
}
}
```
## 6. 직원검색/전화번호검색 API
### 6.1 직원 목록 및 검색
```http
GET /api/v1/tdc114plus/directory/employees
Authorization: Bearer {token}
```
Query:
| 이름 | 필수 | 설명 |
| --- | --- | --- |
| `q` | N | 이름, 전화번호, 부서, 직위, 직무 검색어 |
| `tenantId` | N | 가족사/회사/조직 필터 |
| `tenantSlug` | N | 가족사 slug 필터 |
| `department` | N | 부서명 필터 |
| `limit` | N | 기본 50 |
| `offset` | N | 기본 0 |
| `cursor` | N | cursor pagination 사용 시 |
응답:
```json
{
"items": [
{
"id": "user-uuid",
"name": "홍길동",
"phoneNumber": "+821012345678",
"phoneDisplay": "010-1234-5678",
"email": "user@example.com",
"tenantId": "tenant-uuid",
"tenantName": "한맥",
"tenantSlug": "hanmac",
"department": "기술연구소",
"grade": "책임",
"position": "팀장",
"jobTitle": "개발",
"status": "active",
"profileImageUrl": null,
"sortOrder": 100
}
],
"limit": 50,
"offset": 0,
"total": 1,
"nextCursor": ""
}
```
검색 규칙:
- `q`는 이름, 전화번호, 부서, 직위, 직책, 직무, 이메일 일부를 대상으로 한다.
- 전화번호 검색은 숫자만 입력해도 매칭되도록 서버에서 정규화한다.
- 기본 노출 대상은 조직도 표시 가능한 사용자 상태로 제한한다.
- Baron SSO 기준 `active`, `temporary_leave`, `suspended`는 조직도 노출 후보로 볼 수 있다.
- `baron_guest`, `extended_leave`, `archived`는 기본 제외한다.
### 6.2 직원 상세
```http
GET /api/v1/tdc114plus/directory/employees/{employeeId}
Authorization: Bearer {token}
```
응답:
```json
{
"id": "user-uuid",
"name": "홍길동",
"phoneNumber": "+821012345678",
"phoneDisplay": "010-1234-5678",
"email": "user@example.com",
"tenantId": "tenant-uuid",
"tenantName": "한맥",
"tenantSlug": "hanmac",
"joinedTenants": [
{
"id": "tenant-uuid",
"name": "한맥",
"slug": "hanmac",
"type": "COMPANY",
"parentId": "parent-tenant-uuid"
}
],
"department": "기술연구소",
"grade": "책임",
"position": "팀장",
"jobTitle": "개발",
"status": "active",
"profileImageUrl": null,
"actions": {
"call": true,
"sms": true,
"email": true
}
}
```
## 7. 가족사 필터/조직도 API
### 7.1 가족사/조직 필터 목록
```http
GET /api/v1/tdc114plus/organization/tenants
Authorization: Bearer {token}
```
응답:
```json
{
"items": [
{
"id": "tenant-uuid",
"name": "한맥",
"slug": "hanmac",
"type": "COMPANY",
"parentId": "hanmac-family-root",
"memberCount": 120,
"totalMemberCount": 350
}
],
"generatedAt": "2026-07-02T12:00:00Z"
}
```
### 7.2 조직도 snapshot
```http
GET /api/v1/tdc114plus/organization/orgchart
Authorization: Bearer {token}
```
Query:
| 이름 | 필수 | 설명 |
| --- | --- | --- |
| `tenantId` | N | 특정 가족사/조직 하위만 조회 |
| `refresh` | N | 서버 캐시 refresh 요청. 기본 `false` |
응답:
```json
{
"tenants": [
{
"id": "tenant-uuid",
"name": "기술연구소",
"slug": "rnd",
"type": "ORGANIZATION",
"parentId": "company-tenant-uuid",
"memberCount": 12,
"totalMemberCount": 38
}
],
"employees": [
{
"id": "user-uuid",
"name": "홍길동",
"phoneNumber": "+821012345678",
"phoneDisplay": "010-1234-5678",
"tenantId": "tenant-uuid",
"tenantName": "기술연구소",
"tenantSlug": "rnd",
"department": "기술연구소",
"grade": "책임",
"position": "팀장",
"jobTitle": "개발",
"status": "active"
}
],
"generatedAt": "2026-07-02T12:00:00Z",
"cache": {
"source": "redis",
"hit": true,
"ttlSeconds": 300
}
}
```
구현 참고:
- 기존 `GET /api/v1/admin/orgchart/snapshot``tenants`, `users`, `generatedAt`, `cache` 구조를 가진다.
- `tdc114plus` 응답은 앱 의미에 맞춰 `users` 대신 `employees`를 사용한다.
- 기존 admin/orgFront API를 직접 변경하지 않고 별도 DTO에서 변환한다.
## 8. 즐겨찾기 API
1차 구현은 앱 로컬 저장을 기본으로 한다.
서버 동기화는 후속 단계에서 검토한다.
후속 후보:
```http
GET /api/v1/tdc114plus/favorites
PUT /api/v1/tdc114plus/favorites
```
## 9. 오류 응답 공통 형식
Baron SSO API 설계 정책에 맞춰 신규 API는 `code`를 기본 포함한다.
```json
{
"error": "사람이 읽을 수 있는 메시지",
"code": "machine_readable_code",
"details": {}
}
```
공통 오류:
| HTTP | code | 설명 |
| --- | --- | --- |
| 400 | `invalid_request` | 요청 형식 오류 |
| 401 | `unauthorized` | 토큰 없음/만료 |
| 403 | `forbidden` | tdc114plus 사용 권한 없음 |
| 404 | `not_found` | 직원/조직 없음 |
| 429 | `rate_limited` | 과도한 요청 |
| 503 | `dependency_unavailable` | Kratos, Redis, DB 등 의존성 장애 |
## 10. 개인정보/보안 정책
- 전화번호 원문은 로그에 남기지 않고 마스킹 또는 정규화 값 일부만 기록한다.
- 직원 검색/상세 조회는 감사 로그 대상으로 둔다.
- 대량 조회, 짧은 시간 내 반복 조회는 이상 조회 탐지 후보로 기록한다.
- 1차 구현에서는 Baron SSO 등록 사용자에게 직원 검색/조직도 기본 필드를 노출한다.
- 권한별 민감정보 마스킹은 후속 정책에서 확정한다.
- 1차 앱에서는 `call`, `sms` 액션을 제공하되, 앱 내부에서 수신전화식별/수신팝업 기능은 구현하지 않는다.
## 11. Baron SSO 구현 후보
신규 패키지/파일 후보:
```text
backend/internal/domain/tdc114plus_models.go
backend/internal/handler/tdc114plus_handler.go
backend/internal/service/tdc114plus_service.go
backend/internal/service/tdc114plus_service_test.go
backend/internal/handler/tdc114plus_handler_test.go
```
라우트 후보:
```go
tdc114plus := api.Group("/tdc114plus")
tdc114plus.Post("/auth/phone-login", tdc114plusHandler.PhoneLogin)
tdc114plus.Get("/me", requireAnyUser, tdc114plusHandler.GetMe)
tdc114plus.Get("/directory/employees", requireAnyUser, tdc114plusHandler.ListEmployees)
tdc114plus.Get("/directory/employees/:id", requireAnyUser, tdc114plusHandler.GetEmployee)
tdc114plus.Get("/organization/tenants", requireAnyUser, tdc114plusHandler.ListTenants)
tdc114plus.Get("/organization/orgchart", requireAnyUser, tdc114plusHandler.GetOrgChart)
```
## 12. 구현 전 확인사항
확정되어 바로 진행 가능한 항목:
- 전용 namespace `/api/v1/tdc114plus`
- 전용 DTO
- 기존 admin/user/orgchart API는 변경하지 않음
- 직원/조직 데이터는 기존 orgchart snapshot 및 identity mirror/read model 기반으로 변환
- 전화번호 로그인은 1차에서 등록자 확인 + rate limit + audit + generic error message 기준으로 구현
- token은 1차에서 기존 Baron SSO session token 재사용
- 개인정보는 1차에서 Baron SSO 등록 사용자에게 기본 필드 노출
- 즐겨찾기는 1차에서 앱 로컬 저장
운영 배포 전 재검토 항목:
- SMS OTP, 기기 등록, 내부망 제한, 추가 인증 중 하나 이상 도입 여부
- 앱 전용 access token 또는 refresh token 분리 여부
- 권한별 개인정보 마스킹 범위
- 즐겨찾기 서버 동기화 API 추가 여부
@@ -1,112 +0,0 @@
# tdc114plus 개발 정책
작성일: 2026-07-02
목적: `tdc114plus` 개발 중 Baron SSO 연동 방식, 개발 도구, AI coding assistant 활용 기준, Flutter/플랫폼 구현 원칙을 정리한다.
## 1. Baron SSO 연동 원칙
`tdc114plus` 개발 중 Baron SSO 소스코드와 연동이 필요한 부분은 기존 Baron SSO 소스코드를 임의로 변경해서 맞추지 않는다.
기본 원칙은 다음과 같다.
- `tdc114plus`에 필요한 데이터 요청, 인증 확인, 조직/직원 데이터 제공은 Baron SSO 쪽에 필요한 API를 생성하여 진행한다.
- 기존 Baron SSO의 로그인, 세션, consent, userfront, orgFront 동작을 직접 변형하지 않는다.
- 기존 API 응답 구조를 `tdc114plus` 요구사항에 맞춰 임의로 변경하지 않는다.
- `tdc114plus` 전용 응답 형식이 필요하면 별도 API endpoint 또는 별도 response DTO를 둔다.
- Baron SSO 소스 수정이 필요한 경우 `/home/ubuntu/workspace/baron-sso-tdc114plus-api``feature/tdc114plus-api` 브랜치에서 진행한다.
- `tdc114plus` 앱 저장소에는 Flutter 앱 코드, API client, model, provider, test를 둔다.
- Baron SSO backend/orgFront 변경과 `tdc114plus` Flutter 앱 변경은 저장소와 커밋을 분리한다.
## 2. VS Code 기반 AI 개발 방식
앱 개발은 VS Code를 기본 IDE로 두고, AI coding assistant를 활용한 개발 방식을 권장한다.
목표:
- 반복적인 Flutter 화면/상태관리 코드 작성 속도를 높인다.
- API 계약 변경 시 model, service, provider, test 코드를 일관되게 갱신한다.
- 문서와 코드의 불일치를 줄인다.
- 보안 민감 영역은 AI가 제안하더라도 사람이 반드시 리뷰한다.
권장 VS Code 구성:
| 항목 | 내용 |
| --- | --- |
| Flutter/Dart extension | Flutter 개발, debug, format, test 실행 |
| REST Client 또는 Thunder Client | API 계약 검증 |
| Docker extension | mock/preview 서버 실행 |
| Git/Gitea 연동 | branch, commit, PR 확인 |
| AI coding assistant | 코드 생성, 리팩터링, 테스트 초안, 문서화 보조 |
AI 활용 절차:
1. 작업 전 정책 문서와 API 계약을 먼저 확인한다.
2. AI에게 변경 범위를 명확히 지시한다.
3. 생성된 코드는 반드시 `flutter analyze`와 테스트를 통과시킨다.
4. API 계약 변경이 있으면 문서, model, service, provider, test를 함께 갱신한다.
5. 보안 민감 코드, 인증 코드, 개인정보 처리 코드는 사람이 직접 리뷰한다.
## 3. Flutter 공통 구현 원칙
- 공통 Flutter 코드는 처음부터 Android/iOS 모두를 고려해 작성한다.
- 화면, 상태관리, API client, repository, model은 플랫폼 공통 코드로 우선 설계한다.
- 플랫폼별 차이가 있는 기능은 공통 interface를 먼저 만들고 Android/iOS 구현체를 분리한다.
- 공지사항, 전자결재, 수신전화식별, 수신팝업은 1차 범위에서 보류한다.
- 1차 범위는 직원검색, 전화번호검색, 가족사 필터, 조직도, 직원목록, 전화걸기, 문자보내기, 즐겨찾기에 집중한다.
## 4. 플랫폼별 구현 원칙
- 플랫폼별 네이티브 기능은 Android에서 먼저 PoC를 완성한 뒤 iOS로 확장한다.
- iOS를 너무 늦게 검증하지 않는다.
- PoC 중에도 최소한 WebView, 로그인 세션, APNs 준비 가능 여부는 확인한다.
- 푸시, 생체 인증, 보안 저장소, bridge는 플랫폼별 차이가 크므로 공통 인터페이스와 플랫폼 구현체를 분리한다.
- 운영 배포 전에는 Android/iOS 모두 동일한 보안 기준을 통과해야 한다.
## 5. 검증 원칙
Flutter 앱 변경 시 최소 검증:
```bash
./scripts/flutter-docker.sh analyze
./scripts/flutter-docker.sh test
```
상세 테스트 기준은 아래 정식 정책을 따른다.
- `docs/tdc114plus-testing-policy-2026-07-02.md`
Baron SSO API 변경 시 최소 검증:
- 변경한 backend/orgFront 영역의 기존 테스트 확인
- 신규 API handler/service/model 테스트 추가
- 기존 로그인, 세션, userfront, orgFront 주요 흐름 회귀 확인
- API 계약 문서와 구현 응답 형식 일치 확인
## 6. 질문 및 확인 원칙
작업 진행 중 확인사항이나 질문이 발생하면, 먼저 관련 정책/결정/참고 md 파일을 확인한다.
진행 순서:
1. 현재 작업과 관련된 정책 문서를 먼저 확인한다.
2. 정책 문서에서 판단 가능한 내용은 문서 기준으로 진행한다.
3. 정책 간 충돌, 해석 불명확, 보안 영향, 기존 Baron SSO 동작 변경, 배포 영향이 있는 경우에만 사용자에게 질문한다.
4. 사용자에게 질문할 때는 확인한 문서, 판단이 필요한 지점, 가능한 선택지, 권장안을 함께 제시한다.
5. 새로운 결정이 내려지면 관련 md 문서와 작업진행 타임테이블을 갱신한다.
즉, 작업 중 매 판단마다 바로 질문하지 않는다. 먼저 문서를 확인하고, 문서 기준으로 처리 가능한 작업은 진행한다. 문서 확인 후에도 결정이 필요한 경우에만 질문한다.
## 7. 문서 우선순위
개발 중 판단 기준은 아래 순서로 적용한다.
1. `docs/tdc114plus-development-decision-brief-2026-07-01.md`
2. `docs/tdc114plus-work-progress-timetable-2026-07-02.md`
3. `docs/tdc114plus-development-policy-2026-07-02.md`
4. `docs/tdc114plus-testing-policy-2026-07-02.md`
5. `docs/tdc114plus-api-contract-2026-07-02.md`
6. `docs/baron-sso-reference-source-policy-2026-07-02.md`
7. `docs/references/baron-safe-policies/`
Baron Safe 참고 문서는 참고 자료이며, `tdc114plus`의 직접 정책보다 우선하지 않는다.
@@ -1,108 +0,0 @@
# tdc114plus 테스트 자동화 스크립트 계획
작성일: 2026-07-02
상태: v1.0 초기 스크립트 기준
목적: `docs/tdc114plus-testing-policy-2026-07-02.md`에 정의한 테스트 정책을 실제 `scripts/` 파일과 연결하고, 즉시 사용 가능한 스크립트와 향후 구현이 필요한 scaffold 스크립트를 구분한다.
## 1. 즉시 사용 가능한 스크립트
| 스크립트 | 목적 | 실행 예 |
| --- | --- | --- |
| `scripts/format-dart.sh` | Docker Flutter 이미지에서 `dart format lib test` 실행 | `./scripts/format-dart.sh` |
| `scripts/quality-gate.sh` | `flutter analyze`, `flutter test` 순차 실행 | `./scripts/quality-gate.sh` |
| `scripts/generate-release-report.sh` | git 상태와 릴리스 체크리스트 report 생성 | `./scripts/generate-release-report.sh` |
| `scripts/perf_smoke.sh` | 현재 앱/테스트 파일 수와 기본 상태 출력 | `./scripts/perf_smoke.sh` |
## 2. Scaffold 상태의 스크립트
아래 스크립트는 파일은 존재하지만, 기반 기능이 아직 없으므로 실행 시 scaffold 안내와 함께 종료한다.
| 스크립트 | 현재 상태 | 완성 조건 |
| --- | --- | --- |
| `scripts/mock-server.sh` | `status`, `stop`은 가능. `start`는 미구현 안내 | API 계약 기반 mock server 구현 |
| `scripts/save-snapshots.sh` | snapshot source가 있으면 report 경로로 복사 | widget/integration screenshot 또는 snapshot 생성 체계 |
| `scripts/integration_tests.sh` | `TDC114_API_BASE``integration_test/` 필요 | staging API 또는 mock server 기반 integration test |
| `scripts/redteam/run_all.sh` | 현재 AI 기능 없음 안내 | LLM/프롬프트 기반 기능이 실제 추가될 때 |
## 3. Phase별 사용 기준
### Phase 3: Mock 기반 1차 UI
필수:
```bash
./scripts/format-dart.sh
./scripts/quality-gate.sh
```
선택:
```bash
./scripts/save-snapshots.sh
```
단, snapshot 산출물이 생긴 뒤 사용한다.
### Phase 4: 실제 API 연동
필수 후보:
```bash
TDC114_API_BASE=https://staging.example.com ./scripts/integration_tests.sh
```
완성 조건:
- `app/integration_test/` 테스트 추가
- staging 또는 local mock API endpoint 확정
- 민감정보 없는 test fixture 사용
### Phase 5: 핵심 액션
필수 후보:
```bash
./scripts/quality-gate.sh
./scripts/perf_smoke.sh
```
추가 예정:
- 전화걸기/문자보내기 URL 생성 테스트
- 즐겨찾기 로컬 저장소 테스트
### Phase 6: 빌드/배포 준비
필수 후보:
```bash
./scripts/generate-release-report.sh
```
추가 예정:
- Android debug APK build wrapper
- 수동 검증 체크리스트 자동 생성
## 4. 운영 원칙
- 문서에 명령을 추가할 때는 실제 `scripts/` 파일도 함께 추가하거나 scaffold 상태를 명시한다.
- scaffold 스크립트는 조용히 성공하지 않고, 미구현이면 non-zero exit code로 종료한다.
- 실제 CI gate에 연결할 수 있는 스크립트는 `quality-gate.sh`부터 시작한다.
- AI/LLM redteam 자동화는 현재 앱 범위 밖이므로 `redteam/run_all.sh`는 보류 상태로 유지한다.
- 자동화 스크립트 실행 결과는 `docs/test-logs/YYYY-MM-test-execution-log.md`에 월별로 누적 기록한다.
## 5. Playwright MCP 활용 예정
Playwright MCP는 향후 web/preview 기반 화면 확인이 가능해지는 시점부터 테스트 정책에 활용한다.
우선 적용 후보:
- 로그인 화면 smoke test
- 직원목록/검색/가족사 필터 화면 회귀 확인
- 직원 상세 화면 표시 확인
- screenshot 기반 UI 리뷰 자료 생성
- 텍스트 overflow, 주요 버튼 표시, 라우팅 이동 확인
현재는 Flutter web/preview 실행 방식이 확정되지 않았으므로 별도 `scripts/playwright-*` 파일은 만들지 않는다. 실행 방식이 확정되면 Playwright MCP 시나리오 문서와 월별 테스트 로그 기록 형식을 추가한다.

Some files were not shown because too many files have changed in this diff Show More