Compare commits
8
Commits
d5edaf0a4d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6610314548 | ||
|
|
3cea502619 | ||
|
|
a8692319d5 | ||
|
|
a0d393e338 | ||
|
|
cb36612b88 | ||
|
|
ef0d53aa87 | ||
|
|
5d3eee7a16 | ||
|
|
57caca8dc8 |
+12
@@ -33,3 +33,15 @@ Thumbs.db
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
secrets/
|
||||
logs/
|
||||
temp/
|
||||
.tmp/
|
||||
backups/
|
||||
|
||||
# Local Android/ADB state
|
||||
.android-adb/
|
||||
.tools/
|
||||
|
||||
# Local Docker Flutter caches
|
||||
.docker-cache/
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -1,22 +1,82 @@
|
||||
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.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',
|
||||
);
|
||||
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,
|
||||
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 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();
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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(),
|
||||
@@ -15,4 +21,5 @@ final appRouter = GoRouter(
|
||||
builder: (context, state) => const DirectoryScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,148 @@
|
||||
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 _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()));
|
||||
debugPrint(
|
||||
'AuthSessionStore.save token=${response.token} tokenLength=${response.token.length} expiresAt=${response.expiresAt.toUtc().toIso8601String()}',
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
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>),
|
||||
);
|
||||
debugPrint(
|
||||
'AuthSessionStore.load token=${session.token} tokenLength=${session.token.length} expiresAt=${session.expiresAt.toUtc().toIso8601String()} expired=${session.isExpired}',
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_expiresAtKey);
|
||||
await prefs.remove(_userKey);
|
||||
await clearPendingLink();
|
||||
debugPrint('AuthSessionStore.clear');
|
||||
}
|
||||
}
|
||||
|
||||
class StoredAuthSession {
|
||||
const StoredAuthSession({
|
||||
required this.token,
|
||||
required this.expiresAt,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
final String token;
|
||||
final DateTime expiresAt;
|
||||
final LoginUser user;
|
||||
|
||||
bool get isExpired => !expiresAt.isAfter(DateTime.now().toUtc());
|
||||
}
|
||||
|
||||
final authSessionStoreProvider = Provider<AuthSessionStore>((ref) {
|
||||
return const AuthSessionStore();
|
||||
});
|
||||
@@ -111,7 +111,7 @@ class PhoneLoginResponse {
|
||||
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>? ?? {}),
|
||||
);
|
||||
@@ -127,6 +127,99 @@ class PhoneLoginResponse {
|
||||
}
|
||||
}
|
||||
|
||||
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,475 @@
|
||||
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.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 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 requestedTenantSlug = tenantSlug?.trim();
|
||||
|
||||
return _OrgContextCredentialSnapshot(
|
||||
baseUri: baseUri,
|
||||
tenantSlug: requestedTenantSlug == null || requestedTenantSlug.isEmpty
|
||||
? this.tenantSlug
|
||||
: requestedTenantSlug,
|
||||
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,
|
||||
};
|
||||
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),
|
||||
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.appSessionToken,
|
||||
});
|
||||
|
||||
final Uri baseUri;
|
||||
final String tenantSlug;
|
||||
final String appSessionToken;
|
||||
|
||||
String get cacheKey {
|
||||
return [
|
||||
baseUri.toString(),
|
||||
tenantSlug,
|
||||
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);
|
||||
});
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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,36 @@
|
||||
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',
|
||||
),
|
||||
);
|
||||
|
||||
await store.save(response);
|
||||
final loaded = await store.load();
|
||||
|
||||
expect(loaded?.token, 'session-token');
|
||||
expect(loaded?.user.name, 'User One');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,342 @@
|
||||
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.containsKey('X-Baron-Key-ID'), isFalse);
|
||||
expect(request.headers.containsKey('X-Baron-Key-Secret'), isFalse);
|
||||
|
||||
return _jsonResponse(_orgContextResponse());
|
||||
}),
|
||||
baseUri: Uri.parse('https://sadmin.hmac.kr'),
|
||||
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('does not persist or send session org-context credentials', () 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: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final client = OrgContextApiClient(
|
||||
httpClient: MockClient((request) async {
|
||||
expect(request.url.host, 'env.example.test');
|
||||
expect(request.url.queryParameters['tenantSlug'], 'env-family');
|
||||
expect(request.headers['Authorization'], 'Bearer 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://env.example.test'),
|
||||
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'),
|
||||
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'),
|
||||
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'),
|
||||
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'),
|
||||
tenantSlug: 'hanmac-family',
|
||||
);
|
||||
|
||||
await client.fetchOrgContext();
|
||||
await client.fetchOrgContext();
|
||||
|
||||
expect(requestCount, 1);
|
||||
});
|
||||
|
||||
test('uses configured auth-server org-context endpoint without Baron key headers', () 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.containsKey('X-Baron-Key-ID'), isFalse);
|
||||
expect(request.headers.containsKey('X-Baron-Key-Secret'), isFalse);
|
||||
return _jsonResponse(_orgContextResponse());
|
||||
}),
|
||||
baseUri: Uri.parse('https://env.example.test'),
|
||||
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'),
|
||||
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'),
|
||||
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'),
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -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을 사용한다.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,368 @@
|
||||
# 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-20 기준 앱에는 Baron org-context key와 NAVER WORKS secret을 주입하지 않는다. 해당 값은 `tdc114plus-auth` 서버 env에서만 관리하고, 앱은 중계서버 URL과 앱 세션 token만 사용한다
|
||||
- 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 상태를 따른다.
|
||||
@@ -0,0 +1,141 @@
|
||||
# tdc114plus 배포 검토 회의
|
||||
|
||||
작성일: 2026-07-20
|
||||
용도: 회의 현장에서 바로 보고 설명하기 위한 A4 1장 요약본
|
||||
|
||||
## 0. 권장방안 및 결론
|
||||
|
||||
| 항목 | 권장방안 | 판단 이유 |
|
||||
| --- | --- | --- |
|
||||
| 앱 배포 순서 | `staging -> 내부 검증 -> production` | 로그인, 세션, 조직도, 프로필 사진까지 함께 검증해야 하므로 production 직행은 위험 |
|
||||
| 최종 배포 대상 | `tdc114plus` 앱 + `tdc114plus-auth` 서버 | 이 두 축이 실제 운영 구성이고 `baron-sso-tdc114plus-api`는 로컬 개발/검증용 |
|
||||
| `baron-sso-tdc114plus-api` 취급 | 최종 배포 필수 구성에서 제외 | 개발 중 임시 worktree 성격이므로 운영 구조의 필수 요소로 보지 않음 |
|
||||
| `tdc114plus-auth` 운영 형태 | Baron SSO에 흡수하지 않는 `독립 서비스` 유지 | 앱 전용 인증/중계 책임이 있어 저장소, 배포, 장애 대응 경계를 분리하는 편이 안전 |
|
||||
| `tdc114plus-auth` 배치 위치 | 초기에는 기존 운영 인프라 안의 독립 실행 구조 | 처음부터 물리 서버를 새로 만들지 않아도 서비스/설정/배포를 분리해 시작 가능 |
|
||||
| 중계서버 외부 주소 | `114-auth.hmac.kr` 같은 전용 서브도메인 | 앱과 운영자 모두 역할을 이해하기 쉽고, 도메인/인증서/라우팅 관리가 명확 |
|
||||
| 서버 배포 전략 | `블루/그린` 1순위 | 로그인/세션 장애 시 직전 버전으로 빠르게 rollback하기 가장 쉬움 |
|
||||
| 회의용 최종 결론 | `staging 우선`, `auth 독립 서비스`, `블루/그린 채택` | 현재 구조와 운영 안정성을 같이 만족하는 가장 현실적인 1차안 |
|
||||
|
||||
## 1. 이번 회의에서 결정할 핵심
|
||||
|
||||
1. 신규앱 `tdc114plus`를 어떤 순서로 배포할 것인가
|
||||
2. `tdc114plus-auth` 중계서버를 어디에 둘 것인가
|
||||
3. `tdc114plus-auth` 서버 배포 방식은 무엇으로 할 것인가
|
||||
|
||||
## 2. 현재 기준 결론
|
||||
|
||||
- 신규앱은 `production 직행`보다 `staging -> 내부 검증 -> production` 순서가 적절하다.
|
||||
- 최종 배포 직접 대상은 `tdc114plus` 앱과 `tdc114plus-auth` 서버다.
|
||||
- `baron-sso-tdc114plus-api`는 로컬 개발/검증용이므로 최종 배포 필수 구성으로 보지 않는다.
|
||||
- `tdc114plus-auth`는 Baron SSO에 흡수하지 않고 `독립 서비스`로 유지하는 것이 맞다.
|
||||
- `tdc114plus-auth`의 운영 주소는 `114-auth.hmac.kr` 같은 전용 서브도메인 구성이 가장 현실적이다.
|
||||
- 서버 배포 방식은 현재 단계에서는 `블루/그린`이 1순위다.
|
||||
|
||||
## 3. 왜 staging을 먼저 가야 하는가
|
||||
|
||||
신규앱은 단순 화면 앱이 아니라 아래가 함께 묶여 있다.
|
||||
|
||||
- 로그인 시작
|
||||
- 승인 확인
|
||||
- 앱 세션 발급
|
||||
- 직원검색/조직도 연동
|
||||
- 프로필 사진 프록시
|
||||
|
||||
따라서 production에서 처음 검증하면 장애가 바로 운영 이슈가 된다.
|
||||
그래서 Baron SSO 원본 `staging` 연동을 먼저 안정화한 뒤, 실기기 검증이 끝나면 production으로 승격하는 방식이 안전하다.
|
||||
|
||||
## 4. `tdc114plus-auth`는 왜 중요하며 어떻게 봐야 하는가
|
||||
|
||||
`tdc114plus-auth`는 앱 뒤에서 로그인과 중계를 처리하는 핵심 서버다.
|
||||
|
||||
앱이 하는 일:
|
||||
|
||||
- 사용자의 휴대폰에서 실행
|
||||
- `tdc114plus-auth` 호출
|
||||
|
||||
`tdc114plus-auth`가 하는 일:
|
||||
|
||||
- Baron SSO와 통신
|
||||
- 로그인 승인 결과 확인
|
||||
- 앱 세션 발급
|
||||
- 조직도/직원 데이터 중계
|
||||
- 프로필 사진 프록시 처리
|
||||
|
||||
즉 이 서버가 깨지면 앱 로그인과 핵심 조회 기능이 함께 영향을 받는다.
|
||||
|
||||
## 5. `같은 서버에 둔다`는 말의 정확한 의미
|
||||
|
||||
아래 3가지는 서로 다르다.
|
||||
|
||||
- 저장소 분리: Git 저장소를 따로 관리
|
||||
- 서비스 분리: 실행 프로그램을 따로 운영
|
||||
- 물리 서버 분리: 아예 다른 VM/장비에 배치
|
||||
|
||||
현재 권장 방향은 아래다.
|
||||
|
||||
- `tdc114plus`: 별도 저장소
|
||||
- `tdc114plus-auth`: 별도 저장소
|
||||
- Baron SSO: 기존 저장소 유지
|
||||
|
||||
다만 `tdc114plus-auth`는 처음부터 별도 물리 서버를 만들지 않아도 된다.
|
||||
기존 운영 인프라 또는 Baron SSO 인프라 안에서 `독립 프로세스/독립 컨테이너/독립 설정`으로 시작할 수 있다.
|
||||
|
||||
즉 `같은 서버를 쓸 수 있다`는 뜻이지, `같은 저장소나 같은 서비스로 합친다`는 뜻은 아니다.
|
||||
|
||||
## 6. 중계서버 배치 권장안
|
||||
|
||||
가장 현실적인 1차안:
|
||||
|
||||
- 주소: `114-auth.hmac.kr`
|
||||
- 운영 형태: `tdc114plus-auth` 독립 서비스
|
||||
- 배치 위치: 기존 운영 서버 또는 Baron SSO 인프라 안의 독립 실행 단위
|
||||
|
||||
이 안의 장점:
|
||||
|
||||
- 주소가 명확하다
|
||||
- Baron SSO 본체와 서비스 경계가 유지된다
|
||||
- 초기 인프라 부담이 작다
|
||||
- 나중에 별도 서버로 분리하기 쉽다
|
||||
|
||||
## 7. `tdc114plus-auth` 배포 절차 요약
|
||||
|
||||
1. Gitea에서 배포할 버전 또는 태그 확정
|
||||
2. staging 서버에 먼저 배포
|
||||
3. `/health`, 로그인, `link/init`, `link/poll`, 조직도, 프로필 사진 확인
|
||||
4. Android 실기기에서 실제 로그인과 조회 흐름 검증
|
||||
5. 문제 없으면 production 반영
|
||||
6. 배포 직후 로그와 장애 여부 집중 확인
|
||||
7. 문제 시 즉시 rollback
|
||||
|
||||
## 8. 왜 블루/그린이 가장 적합한가
|
||||
|
||||
블루/그린은 기존 운영 서버를 바로 덮어쓰지 않고, 새 버전을 옆에 준비한 뒤 전환하는 방식이다.
|
||||
|
||||
장점:
|
||||
|
||||
- rollback이 빠르다
|
||||
- 로그인/세션 장애가 생겨도 즉시 이전 버전으로 되돌리기 쉽다
|
||||
- 초보자도 구조를 이해하기 쉽다
|
||||
|
||||
현재 `tdc114plus-auth`는 로그인과 세션을 담당하므로, `신구 버전이 섞일 수 있는 롤링`보다 `빠르게 되돌릴 수 있는 블루/그린`이 더 적합하다.
|
||||
|
||||
## 9. 회의에서 꼭 확인할 질문
|
||||
|
||||
1. `tdc114plus-auth`를 올릴 기존 운영 인프라가 있는가
|
||||
2. `114-auth.hmac.kr` 같은 전용 서브도메인을 사용할 수 있는가
|
||||
3. staging 도메인과 production 도메인을 나눌 수 있는가
|
||||
4. HTTPS 인증서와 secret 주입은 누가 담당하는가
|
||||
5. 블루/그린을 할 수 있을 정도의 서버 자원이 있는가
|
||||
6. 장애 시 rollback 책임자와 절차는 어떻게 되는가
|
||||
|
||||
## 10. 회의용 최종 제안 문안
|
||||
|
||||
```text
|
||||
신규앱은 production 직행보다 staging 연동 안정화 후 production으로 승격하는 구조가 안전합니다.
|
||||
|
||||
최종 배포 대상은 tdc114plus 앱과 tdc114plus-auth 서버이며, baron-sso-tdc114plus-api는 로컬 개발용으로 보고 최종 배포 필수 구성에서는 제외하는 것이 맞습니다.
|
||||
|
||||
tdc114plus-auth는 Baron SSO에 흡수하지 않고 독립 서비스로 유지하되, 초기에는 114-auth.hmac.kr 같은 전용 서브도메인과 기존 운영 인프라 안의 독립 실행 구조로 시작하는 것이 현실적입니다.
|
||||
|
||||
서버 배포 방식은 현재 단계에서는 rollback이 빠른 블루/그린을 1순위로 제안합니다.
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,264 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>tdc114plus 배포 검토 회의 1페이지 요약</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 10mm;
|
||||
}
|
||||
|
||||
:root {
|
||||
--text: #172033;
|
||||
--muted: #4f5b73;
|
||||
--line: #bfc8d8;
|
||||
--head: #e9eef7;
|
||||
--accent: #1f4ea3;
|
||||
--soft: #f7f9fc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Noto Sans KR", "Malgun Gothic", sans-serif;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
line-height: 1.28;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 190mm;
|
||||
min-height: 277mm;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 3mm;
|
||||
font-size: 18px;
|
||||
color: var(--accent);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-bottom: 4mm;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 4mm 0 2mm;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 1.5mm 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 1.5mm 0 0 4mm;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.7mm 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin: 1.5mm 0 2.5mm;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 6px 7px;
|
||||
vertical-align: top;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--head);
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4mm;
|
||||
}
|
||||
|
||||
.box {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--soft);
|
||||
padding: 3mm;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.quote {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
padding: 3mm;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>tdc114plus 배포 검토 회의 1페이지 요약</h1>
|
||||
<div class="meta">작성일: 2026-07-20 | 용도: 회의 현장에서 바로 보고 설명하기 위한 A4 1장 요약본</div>
|
||||
|
||||
<h2>권장방안 및 결론</h2>
|
||||
<table>
|
||||
<colgroup>
|
||||
<col style="width: 18%">
|
||||
<col style="width: 28%">
|
||||
<col style="width: 54%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>항목</th>
|
||||
<th>권장방안</th>
|
||||
<th>판단 이유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>앱 배포 순서</td>
|
||||
<td><span class="strong">staging -> 내부 검증 -> production</span></td>
|
||||
<td>로그인, 세션, 조직도, 프로필 사진까지 함께 검증해야 하므로 production 직행은 위험</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>최종 배포 대상</td>
|
||||
<td><span class="strong">tdc114plus 앱 + tdc114plus-auth 서버</span></td>
|
||||
<td>실제 운영 구성의 핵심 두 축이며, <code>baron-sso-tdc114plus-api</code>는 로컬 개발/검증용</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>중계서버 형태</td>
|
||||
<td><span class="strong">Baron SSO에 흡수하지 않는 독립 서비스</span></td>
|
||||
<td>앱 전용 인증/중계 책임이 있어 저장소, 배포, 장애 대응 경계를 분리하는 편이 안전</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>배치 위치</td>
|
||||
<td><span class="strong">기존 운영 인프라 안의 독립 실행 구조</span></td>
|
||||
<td>처음부터 물리 서버를 새로 만들지 않아도 서비스/설정/배포를 분리해 시작 가능</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>중계서버 주소</td>
|
||||
<td><span class="strong">114-auth.hmac.kr</span></td>
|
||||
<td>앱과 운영자 모두 역할을 이해하기 쉽고, 도메인/인증서/라우팅 관리가 명확</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>배포 전략</td>
|
||||
<td><span class="strong">블루/그린 1순위</span></td>
|
||||
<td>로그인/세션 장애 시 직전 버전으로 빠르게 rollback하기 가장 쉬움</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h2>이번 회의 핵심</h2>
|
||||
<ol>
|
||||
<li>신규앱 <code>tdc114plus</code>를 어떤 순서로 배포할 것인가</li>
|
||||
<li><code>tdc114plus-auth</code> 중계서버를 어디에 둘 것인가</li>
|
||||
<li><code>tdc114plus-auth</code> 서버 배포 방식은 무엇으로 할 것인가</li>
|
||||
</ol>
|
||||
|
||||
<h2>왜 staging이 먼저인가</h2>
|
||||
<ul>
|
||||
<li>신규앱은 단순 화면 앱이 아니라 로그인, 승인 확인, 앱 세션, 직원검색/조직도, 프로필 사진 프록시가 함께 묶여 있음</li>
|
||||
<li>production에서 처음 검증하면 장애가 바로 운영 이슈가 됨</li>
|
||||
<li>따라서 Baron SSO 원본 <code>staging</code> 연동을 먼저 안정화한 뒤 production으로 승격하는 구조가 안전</li>
|
||||
</ul>
|
||||
|
||||
<h2>tdc114plus-auth가 중요한 이유</h2>
|
||||
<ul>
|
||||
<li>앱 뒤에서 로그인과 중계를 처리하는 핵심 서버</li>
|
||||
<li>Baron SSO와 통신, 승인 결과 확인, 앱 세션 발급, 조직도/직원 데이터 중계, 프로필 사진 프록시 처리 담당</li>
|
||||
<li>이 서버가 깨지면 앱 로그인과 핵심 조회 기능이 함께 영향을 받음</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>같은 서버에 둔다는 말의 의미</h2>
|
||||
<ul>
|
||||
<li><span class="strong">저장소 분리</span>: Git 저장소를 따로 관리</li>
|
||||
<li><span class="strong">서비스 분리</span>: 실행 프로그램을 따로 운영</li>
|
||||
<li><span class="strong">물리 서버 분리</span>: 아예 다른 VM/장비에 배치</li>
|
||||
</ul>
|
||||
<div class="box">
|
||||
<p><span class="strong">현재 권장 방향</span></p>
|
||||
<ul>
|
||||
<li><code>tdc114plus</code>: 별도 저장소</li>
|
||||
<li><code>tdc114plus-auth</code>: 별도 저장소</li>
|
||||
<li>Baron SSO: 기존 저장소 유지</li>
|
||||
</ul>
|
||||
<p>다만 <code>tdc114plus-auth</code>는 처음부터 별도 물리 서버를 만들지 않아도 되고, 기존 운영 인프라 또는 Baron SSO 인프라 안에서 <span class="strong">독립 프로세스/독립 컨테이너/독립 설정</span>으로 시작할 수 있다.</p>
|
||||
</div>
|
||||
|
||||
<h2>중계서버 배치 권장안</h2>
|
||||
<ul>
|
||||
<li>주소: <code>114-auth.hmac.kr</code></li>
|
||||
<li>운영 형태: <code>tdc114plus-auth</code> 독립 서비스</li>
|
||||
<li>배치 위치: 기존 운영 서버 또는 Baron SSO 인프라 안의 독립 실행 단위</li>
|
||||
<li>장점: 주소 명확, 서비스 경계 유지, 초기 인프라 부담 작음, 나중에 별도 서버 분리 쉬움</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>tdc114plus-auth 배포 절차 요약</h2>
|
||||
<ol>
|
||||
<li>Gitea에서 배포할 버전 또는 태그 확정</li>
|
||||
<li>staging 서버에 먼저 배포</li>
|
||||
<li><code>/health</code>, 로그인, <code>link/init</code>, <code>link/poll</code>, 조직도, 프로필 사진 확인</li>
|
||||
<li>Android 실기기에서 실제 로그인과 조회 흐름 검증</li>
|
||||
<li>문제 없으면 production 반영</li>
|
||||
<li>배포 직후 로그와 장애 여부 집중 확인, 문제 시 즉시 rollback</li>
|
||||
</ol>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h2>왜 블루/그린인가</h2>
|
||||
<ul>
|
||||
<li>기존 운영 서버를 바로 덮어쓰지 않고 새 버전을 옆에 준비한 뒤 전환</li>
|
||||
<li>rollback이 빠름</li>
|
||||
<li>로그인/세션 장애 시 즉시 이전 버전으로 되돌리기 쉬움</li>
|
||||
<li>초보자도 구조를 이해하기 쉬움</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>회의에서 꼭 확인할 질문</h2>
|
||||
<ol>
|
||||
<li><code>tdc114plus-auth</code>를 올릴 기존 운영 인프라가 있는가</li>
|
||||
<li><code>114-auth.hmac.kr</code> 같은 전용 서브도메인을 사용할 수 있는가</li>
|
||||
<li>staging 도메인과 production 도메인을 나눌 수 있는가</li>
|
||||
<li>HTTPS 인증서와 secret 주입은 누가 담당하는가</li>
|
||||
<li>블루/그린을 할 수 있을 정도의 서버 자원이 있는가</li>
|
||||
<li>장애 시 rollback 책임자와 절차는 어떻게 되는가</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>회의용 최종 제안 문안</h2>
|
||||
<div class="quote">
|
||||
신규앱은 production 직행보다 staging 연동 안정화 후 production으로 승격하는 구조가 안전하다. 최종 배포 대상은 tdc114plus 앱과 tdc114plus-auth 서버이며, baron-sso-tdc114plus-api는 로컬 개발용으로 보고 최종 배포 필수 구성에서는 제외하는 것이 맞다. tdc114plus-auth는 Baron SSO에 흡수하지 않고 독립 서비스로 유지하되, 초기에는 114-auth.hmac.kr 같은 전용 서브도메인과 기존 운영 인프라 안의 독립 실행 구조로 시작하는 것이 현실적이다. 서버 배포 방식은 현재 단계에서는 rollback이 빠른 블루/그린을 1순위로 제안한다.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,431 @@
|
||||
# tdc114plus 신규앱 배포 회의 결과 정리
|
||||
|
||||
작성일: 2026-07-21
|
||||
회의일: 2026-07-20
|
||||
상태: 회의 결과 초안
|
||||
|
||||
## 0. 팀장 확인 완료 내용
|
||||
|
||||
아래 내용은 2026-07-21 팀장 확인 완료 기준으로 정리한다.
|
||||
|
||||
1. 신규앱 `tdc114plus`와 중계서버 `tdc114plus-auth` 모두 Cloudflare 관리 체계 안에서 운영한다.
|
||||
|
||||
2. `tdc114plus`는 Gitea에 코드가 올라가면 Gitea Actions로 APK를 자동 빌드하고, 결과물을 Cloudflare에 배포하는 흐름으로 진행한다.
|
||||
|
||||
3. `114.hmac.kr`은 staging용 앱 다운로드 및 검증 경로, `114.brsw.kr`은 production용 앱 다운로드 경로로 진행한다.
|
||||
|
||||
4. 사용자는 별도 복잡한 절차 없이 도메인에 접속하여 APK를 다운로드하거나 설치 안내 페이지에 접근하는 방식으로 진행한다.
|
||||
|
||||
5. `tdc114plus-auth`도 Cloudflare Workers 쪽에서 운영하는 방향으로 검토한다.
|
||||
|
||||
6. 다만 현재 `tdc114plus-auth`는 Go 기반 서버이므로 Cloudflare Workers에 그대로 올릴 수 있는지 기술 검토가 필요하다.
|
||||
|
||||
7. Go 기반 서버를 그대로 Workers에서 운영하기 어렵거나 안정성이 낮다면, TypeScript 등 Workers 지원 언어로 재구현하는 방안을 검토한다.
|
||||
|
||||
8. 우선 현재 `tdc114plus-auth`의 인증, 조직도, 프로필사진 중계 기능이 Cloudflare Workers 환경에서 지원 가능한지 PoC 수준으로 확인한다.
|
||||
|
||||
9. 검토 결과를 기준으로 `Go 유지 + Cloudflare proxy/Tunnel 방식`과 `Workers 지원 언어로 재구현 방식` 중 안정적인 방향을 비교해 제안한다.
|
||||
|
||||
## 1. 회의 배경
|
||||
|
||||
2026-07-20 신규앱 배포 방식과 관련하여 배포 방향을 논의했다.
|
||||
|
||||
회의 전 문서에서는 Android APK 배포, `tdc114plus-auth` 중계서버 배포, staging/production 분리, 블루/그린 배포 전략을 중심으로 검토했다.
|
||||
|
||||
회의에서 추가로 확인된 큰 방향은 아래와 같다.
|
||||
|
||||
- 앱 배포는 Cloudflare를 활용하는 방향으로 검토한다.
|
||||
- `tdc114plus-auth` 중계서버도 Cloudflare에서 관리하는 방향으로 검토한다.
|
||||
- Gitea와 Gitea Actions를 활용해 빌드/배포 자동화 흐름을 만든다.
|
||||
- 사용자는 도메인 접속만으로 APK 다운로드 또는 다운로드 안내 페이지에 접근할 수 있게 한다.
|
||||
- `114.hmac.kr`은 staging, `114.brsw.kr`은 production으로 본다.
|
||||
|
||||
## 2. 회의 중 언급된 주요 단어
|
||||
|
||||
회의 중 언급된 단어는 아래와 같다.
|
||||
|
||||
- CI/CD
|
||||
- Gitea Actions
|
||||
- 결과물 다운로드 경로
|
||||
- 정적 페이지
|
||||
- 배포
|
||||
- 파이프라인
|
||||
- Gitea
|
||||
- 어떤 flow로 배포하겠다
|
||||
- WebAssembly 기반
|
||||
- Cloudflare
|
||||
- Cloudflare Workers 기반 변경
|
||||
- 앱 배포는 심플
|
||||
- 사용자가 도메인만 칠 때는 앱 APK가 다운로드되게
|
||||
- `114.hmac.kr`은 staging
|
||||
- `114.brsw.kr`은 production
|
||||
|
||||
## 3. 단어별 해석
|
||||
|
||||
### 3.1 CI/CD
|
||||
|
||||
코드 변경 후 빌드, 산출물 생성, 배포까지 자동화하는 흐름을 의미하는 것으로 본다.
|
||||
|
||||
즉 개발자가 매번 수동으로 APK를 빌드하고 전달하는 방식이 아니라, Git push 또는 tag 생성 후 자동으로 빌드/배포가 이어지는 구조를 의도한 것으로 해석된다.
|
||||
|
||||
### 3.2 Gitea Actions
|
||||
|
||||
Gitea 저장소에서 push, tag, release 같은 이벤트가 발생했을 때 자동 작업을 실행하는 기능이다.
|
||||
|
||||
이번 회의 맥락에서는 아래 역할로 해석된다.
|
||||
|
||||
- Flutter Android 앱 빌드
|
||||
- APK 또는 AAB 생성
|
||||
- 빌드 결과물 저장
|
||||
- Cloudflare 배포 또는 업로드 작업 실행
|
||||
|
||||
### 3.3 결과물 다운로드 경로
|
||||
|
||||
빌드된 APK를 사용자가 받을 수 있는 최종 URL을 의미한다.
|
||||
|
||||
예상 예시는 아래와 같다.
|
||||
|
||||
- staging: `https://114.hmac.kr`
|
||||
- production: `https://114.brsw.kr`
|
||||
- 직접 APK 경로: `https://114.hmac.kr/downloads/tdc114plus-staging.apk`
|
||||
- 직접 APK 경로: `https://114.brsw.kr/downloads/tdc114plus.apk`
|
||||
|
||||
정확한 경로명은 Cloudflare 구성 방식과 파일 저장 위치가 정해진 뒤 확정한다.
|
||||
|
||||
### 3.4 정적 페이지
|
||||
|
||||
APK 다운로드 버튼, 버전 정보, 설치 안내, 변경 이력 등을 보여주는 단순 웹페이지를 의미하는 것으로 본다.
|
||||
|
||||
사용자가 도메인에 접속했을 때 바로 APK 파일이 다운로드되게 할 수도 있지만, 운영상으로는 정적 안내 페이지를 먼저 보여주고 다운로드 버튼을 제공하는 방식도 가능하다.
|
||||
|
||||
### 3.5 배포 파이프라인
|
||||
|
||||
코드가 저장소에 올라간 뒤 사용자에게 전달 가능한 산출물이 나오기까지의 고정 절차를 의미한다.
|
||||
|
||||
회의 의도를 반영하면 기본 흐름은 아래와 같다.
|
||||
|
||||
```text
|
||||
개발자 코드 수정
|
||||
-> Gitea push 또는 release tag 생성
|
||||
-> Gitea Actions 실행
|
||||
-> Flutter Android APK 빌드
|
||||
-> 빌드 결과물 저장
|
||||
-> Cloudflare 업로드 또는 배포
|
||||
-> 사용자는 도메인 접속
|
||||
-> APK 다운로드 또는 설치 안내 확인
|
||||
```
|
||||
|
||||
### 3.6 WebAssembly 기반
|
||||
|
||||
이 표현은 Flutter Web 또는 향후 웹 실행 형태까지 염두에 둔 표현일 수 있다.
|
||||
|
||||
다만 현재 신규앱의 1차 배포 대상은 Android APK이므로, WebAssembly 기반 배포는 아래처럼 분리해서 봐야 한다.
|
||||
|
||||
- Android APK 배포: 현재 우선순위
|
||||
- Flutter Web/WebAssembly 기반 앱 제공: 후속 검토 가능성
|
||||
|
||||
따라서 이 단어만으로 Android APK 대신 웹앱을 우선한다는 뜻으로 확정하면 안 된다.
|
||||
|
||||
### 3.7 Cloudflare
|
||||
|
||||
회의의 핵심 인프라 방향으로 보인다.
|
||||
|
||||
현재 해석상 Cloudflare는 아래 역할을 담당할 수 있다.
|
||||
|
||||
- 정적 다운로드 페이지 제공
|
||||
- APK 파일 다운로드 경로 제공
|
||||
- staging/production 도메인 라우팅
|
||||
- `tdc114plus-auth` API 또는 중계 기능 관리
|
||||
- Cloudflare Workers를 통한 요청 처리
|
||||
- 캐시, HTTPS, 접근 제어 등 운영 보조 기능 제공
|
||||
|
||||
### 3.8 Cloudflare Workers 기반 변경
|
||||
|
||||
단순 정적 파일 호스팅만이 아니라 Workers를 활용해 요청 흐름을 제어하려는 의도로 볼 수 있다.
|
||||
|
||||
예상 가능한 역할은 아래와 같다.
|
||||
|
||||
- `/` 접속 시 최신 APK 다운로드 페이지로 라우팅
|
||||
- `/download` 접속 시 최신 APK 파일로 redirect
|
||||
- staging/prod 도메인별 다른 APK 제공
|
||||
- 버전 정보 JSON 제공
|
||||
- User-Agent 기준 Android 사용자에게 설치 안내 제공
|
||||
- `tdc114plus-auth`가 담당하던 일부 중계 API를 Workers 기반으로 제공하거나, Workers가 별도 auth 서버 앞단 proxy 역할을 수행
|
||||
|
||||
회의 후 추가 해석:
|
||||
|
||||
- 팀장 의도는 `tdc114plus-auth`도 가능하면 Cloudflare Workers 쪽에서 직접 관리/실행하는 방향에 가까운 것으로 보인다.
|
||||
- 현재 Go 기반 `tdc114plus-auth`를 Cloudflare Workers에서 그대로 지원하는지 확인해야 한다.
|
||||
- 그대로 지원이 어렵다면 Workers에서 안정적으로 지원되는 언어로 중계서버를 변경하거나 재구현하는 방안도 검토 대상이다.
|
||||
|
||||
## 4. 도출되는 팀장 의도
|
||||
|
||||
회의에서 나온 단어를 종합하면, 의도는 아래처럼 정리된다.
|
||||
|
||||
`Gitea에 코드가 올라가면 Gitea Actions가 자동으로 앱을 빌드하고, 결과 APK와 필요한 중계 기능을 Cloudflare 쪽에 배포하여 사용자가 도메인 접속만으로 다운로드하거나 앱 기능을 사용할 수 있게 한다.`
|
||||
|
||||
즉 핵심은 아래 3가지다.
|
||||
|
||||
1. 수동 APK 전달을 줄인다.
|
||||
2. 빌드와 배포 흐름을 Gitea Actions 중심으로 자동화한다.
|
||||
3. 사용자 접근 경로와 중계서버 운영 경로를 Cloudflare 도메인/Workers 중심으로 단순화한다.
|
||||
|
||||
## 5. 예상 배포 구조
|
||||
|
||||
현재 회의 결과 기준으로 예상되는 구조는 아래와 같다.
|
||||
|
||||
```text
|
||||
tdc114plus Git 저장소
|
||||
-> Gitea Actions
|
||||
-> Flutter Android build
|
||||
-> APK 산출물 생성
|
||||
-> Cloudflare 업로드 또는 배포
|
||||
|
||||
tdc114plus-auth Git 저장소
|
||||
-> Gitea Actions
|
||||
-> Cloudflare Workers 또는 Cloudflare 관리 런타임 배포
|
||||
-> auth/profile/org-context 중계 기능 제공
|
||||
|
||||
Cloudflare
|
||||
-> 114.hmac.kr : staging 앱 다운로드/안내
|
||||
-> 114.brsw.kr : production 앱 다운로드/안내
|
||||
-> auth/API 경로 : tdc114plus-auth 중계 기능
|
||||
```
|
||||
|
||||
## 6. staging / production 도메인 구분
|
||||
|
||||
회의에서 언급된 기준은 아래와 같다.
|
||||
|
||||
| 구분 | 도메인 | 용도 |
|
||||
| --- | --- | --- |
|
||||
| staging | `114.hmac.kr` | 내부 검증, 테스트 APK, 사전 배포 |
|
||||
| production | `114.brsw.kr` | 실제 운영 사용자 대상 APK |
|
||||
|
||||
이 구분은 앱 다운로드 경로뿐 아니라 앱 내부 API base URL, auth 서버 base URL, org-context base URL과도 연결된다.
|
||||
|
||||
따라서 APK 빌드 시점에 아래 값들이 환경별로 분리되어야 한다.
|
||||
|
||||
- 앱 API base URL
|
||||
- auth broker base URL
|
||||
- org-context API base URL
|
||||
- 빌드 타입 또는 배포 채널명
|
||||
- 앱 버전/빌드번호
|
||||
|
||||
## 7. 앱 배포와 서버 배포의 구분
|
||||
|
||||
회의 내용 중 "앱 배포는 심플"이라는 표현은 Android APK 배포를 단순화하자는 뜻으로 해석된다.
|
||||
|
||||
다만 `tdc114plus-auth` 서버/API 배포는 APK 배포와 역할이 다르므로 Cloudflare 안에서 함께 관리하더라도 구분해서 봐야 한다.
|
||||
|
||||
구분하면 아래와 같다.
|
||||
|
||||
| 대상 | 성격 | 배포 방식 |
|
||||
| --- | --- | --- |
|
||||
| `tdc114plus` APK | 사용자 휴대폰에 설치되는 앱 산출물 | Gitea Actions 빌드 후 Cloudflare 다운로드 제공 |
|
||||
| `tdc114plus-auth` | 로그인/조직도/프로필 이미지 중계 기능 | Cloudflare Workers 또는 Cloudflare 앞단 proxy/관리 런타임으로 배포 검토 |
|
||||
|
||||
즉 Cloudflare 기반으로 두 대상을 모두 관리하더라도, 앱 APK 배포와 `tdc114plus-auth` API 배포는 서로 다른 파이프라인/검증 기준을 가져야 한다.
|
||||
|
||||
## 7.1 `tdc114plus-auth`를 Cloudflare에서 관리한다는 의미
|
||||
|
||||
회의 내용상 `tdc114plus-auth`도 Cloudflare에서 관리하자는 방향이 추가로 확인되었다.
|
||||
|
||||
이 말은 최소한 아래 가능성을 포함한다.
|
||||
|
||||
1. Cloudflare Workers로 `tdc114plus-auth` 기능을 직접 구현/배포한다.
|
||||
2. 기존 `tdc114plus-auth` 서버는 유지하되 Cloudflare Workers가 앞단 proxy 또는 routing 계층을 담당한다.
|
||||
3. Cloudflare Pages/Workers/R2 등을 조합해 앱 다운로드와 auth 중계를 같은 Cloudflare 운영 체계에서 관리한다.
|
||||
|
||||
현재 바로 확정하면 안 되는 부분:
|
||||
|
||||
- 현재 Go 기반 `tdc114plus-auth`를 Workers로 그대로 올릴 수 있는지
|
||||
- Workers가 네이버웍스 OAuth, Baron SSO 연동, RSA/JWT 처리, 외부 API 호출을 모두 안정적으로 감당할 수 있는지
|
||||
- Cloudflare에서 secret을 어떻게 관리할지
|
||||
- Workers가 아닌 별도 서버를 Cloudflare Tunnel 또는 proxy 뒤에 둘지
|
||||
|
||||
따라서 현재 문서 기준으로는 아래처럼 정리한다.
|
||||
|
||||
`tdc114plus-auth`도 Cloudflare 관리 대상에 포함한다. 다만 구현 방식은 Workers 직접 이식, Workers proxy, Cloudflare Tunnel/외부 서버 연동 중에서 별도 검토 후 확정한다.
|
||||
|
||||
## 7.2 Cloudflare Workers가 현재 Go 기반 `tdc114plus-auth`를 그대로 지원하는지
|
||||
|
||||
현재 판단은 아래와 같다.
|
||||
|
||||
`Go 기반 tdc114plus-auth를 Cloudflare Workers에 그대로 올리는 것은 어렵거나 위험하다.`
|
||||
|
||||
이유는 아래와 같다.
|
||||
|
||||
- Cloudflare Workers의 기본 실행 모델은 일반적인 장기 실행 서버 프로세스가 아니다.
|
||||
- 현재 `tdc114plus-auth`는 Go의 `net/http` 서버로 `:5001` 포트를 열고 계속 떠 있는 구조다.
|
||||
- Workers는 `fetch(request, env, ctx)` 같은 요청 단위 실행 모델에 가깝다.
|
||||
- 현재 Go 서버는 `os.ReadFile()`로 RSA private/public key 파일을 읽는다.
|
||||
- Workers에서는 이런 파일 기반 secret 관리보다 Cloudflare Secrets/env 기반 관리가 필요하다.
|
||||
- 현재 Go 서버는 메모리 map으로 `pendingRef` 상태를 저장한다.
|
||||
- Workers에서는 인스턴스 메모리 지속성을 전제로 하면 안 되므로 KV, Durable Objects, D1 같은 외부 상태 저장소가 필요하다.
|
||||
|
||||
정리하면 현재 구조는 아래처럼 바뀌어야 한다.
|
||||
|
||||
```text
|
||||
현재 Go 서버 방식
|
||||
프로세스 실행
|
||||
-> :5001 listen
|
||||
-> 요청 처리
|
||||
-> 메모리 map 상태 저장
|
||||
-> 파일에서 RSA key 읽기
|
||||
|
||||
Cloudflare Workers 방식
|
||||
fetch(request, env, ctx)
|
||||
-> 요청 처리
|
||||
-> Cloudflare Secrets에서 key/secret 읽기
|
||||
-> KV/Durable Object/D1 등에 pending 상태 저장
|
||||
```
|
||||
|
||||
## 7.3 Cloudflare Workers 지원 언어 기준 검토
|
||||
|
||||
Cloudflare Workers는 일반적으로 아래 언어/런타임이 주력 검토 대상이다.
|
||||
|
||||
- JavaScript
|
||||
- TypeScript
|
||||
- Python
|
||||
- Rust
|
||||
- WebAssembly 기반 언어
|
||||
|
||||
Go도 WebAssembly로 컴파일하면 일부 시나리오에서 가능성이 있을 수 있다.
|
||||
|
||||
하지만 현재 `tdc114plus-auth`처럼 아래 기능을 가진 서버를 Go/Wasm으로 그대로 이식하는 것은 안정성 관점에서 신중해야 한다.
|
||||
|
||||
- Baron SSO 외부 API 호출
|
||||
- NAVER WORKS OAuth/JWT/RSA 서명
|
||||
- 앱 세션 JWT 발급
|
||||
- org-context proxy
|
||||
- profile-image proxy
|
||||
- pending login 상태 저장
|
||||
- 파일 기반 RSA key 로딩
|
||||
- Go `net/http` 서버 실행
|
||||
|
||||
따라서 현재 추천은 아래다.
|
||||
|
||||
1. Go 그대로 Workers에 올리는 것은 1순위로 보지 않는다.
|
||||
2. Workers 직접 운영이 목표라면 TypeScript Workers 재구현을 우선 검토한다.
|
||||
3. Rust Workers는 성능과 타입 안정성 장점이 있지만 개발 난이도가 더 높다.
|
||||
4. Python Workers는 가능성을 검토할 수 있으나, 현재 Workers 생태계와 예제/운영 경험 면에서는 TypeScript가 더 무난하다.
|
||||
|
||||
## 7.4 현재 `tdc114plus-auth` 기능별 Workers 이식 영향
|
||||
|
||||
| 기능 | 현재 구현 | Workers 이식 시 검토 |
|
||||
| --- | --- | --- |
|
||||
| HTTP 서버 | Go `net/http`, `ListenAndServe(:5001)` | Workers `fetch()` 핸들러로 전환 필요 |
|
||||
| 로그인 시작 | Baron SSO headless/link API 호출 | `fetch()` 기반 외부 API 호출로 구현 가능 |
|
||||
| 로그인 poll | 메모리 map pending 상태 사용 | KV 또는 Durable Objects로 상태 저장 필요 |
|
||||
| 앱 세션 발급 | Go에서 JWT 생성/서명 | Web Crypto 또는 라이브러리로 재구현 필요 |
|
||||
| RSA key 관리 | 파일 경로에서 private/public key 읽기 | Cloudflare Secrets/env로 전환 필요 |
|
||||
| org-context proxy | Baron API Key로 외부 API 호출 | Workers에서 구현 가능하나 secret 관리 필요 |
|
||||
| NAVER WORKS 사진 | OAuth JWT bearer + photo redirect 처리 | Workers에서 구현 가능하나 RSA/JWT 처리 검증 필요 |
|
||||
| profile image fallback | R2/공개 이미지 URL 확인 | Workers에서 구현 가능 |
|
||||
| 로그 | Go log 출력 | Workers logs/observability 기준으로 전환 필요 |
|
||||
| health check | `/health` route | Workers route로 구현 가능 |
|
||||
|
||||
## 7.5 가능한 전환 방식
|
||||
|
||||
### 1안: Go 기반 `tdc114plus-auth`를 그대로 Workers에 올리기
|
||||
|
||||
현재 기준 비추천이다.
|
||||
|
||||
이유:
|
||||
|
||||
- Workers 런타임과 Go 장기 실행 서버 모델이 다르다.
|
||||
- 파일 기반 key 로딩과 메모리 상태 저장 방식이 맞지 않는다.
|
||||
- Go/Wasm으로 가능하더라도 운영 안정성과 디버깅 난이도가 높을 수 있다.
|
||||
|
||||
### 2안: TypeScript 기반 Cloudflare Workers로 재구현
|
||||
|
||||
현재 가장 현실적인 검토안이다.
|
||||
|
||||
장점:
|
||||
|
||||
- Workers 기본 모델과 가장 잘 맞는다.
|
||||
- Cloudflare 문서와 예제가 많다.
|
||||
- Secrets, KV, Durable Objects, R2 연동이 자연스럽다.
|
||||
|
||||
주의:
|
||||
|
||||
- 기존 Go 서버의 인증/JWT/RSA 로직을 TypeScript로 재검증해야 한다.
|
||||
- 보안 로직이므로 단순 포팅이 아니라 테스트와 검증이 필요하다.
|
||||
|
||||
### 3안: 기존 Go 서버 유지 + Cloudflare Workers/Tunnel/proxy 앞단 구성
|
||||
|
||||
중간 단계로 검토 가능하다.
|
||||
|
||||
장점:
|
||||
|
||||
- 기존 Go 서버 코드를 크게 버리지 않아도 된다.
|
||||
- Cloudflare 도메인/보안/라우팅 체계는 사용할 수 있다.
|
||||
|
||||
단점:
|
||||
|
||||
- 팀장이 말한 "Workers에서 직접 관리" 의도와는 다를 수 있다.
|
||||
- 별도 서버 운영 부담이 남는다.
|
||||
|
||||
## 7.6 현재 판단
|
||||
|
||||
현재 회의 결과와 기술 제약을 함께 보면 아래처럼 정리한다.
|
||||
|
||||
- `tdc114plus` 앱 배포는 Gitea Actions와 Cloudflare 조합으로 진행하는 방향이 타당하다.
|
||||
- `tdc114plus-auth`도 Cloudflare 관리 대상으로 보는 방향은 맞다.
|
||||
- 다만 현재 Go 기반 `tdc114plus-auth`를 Workers에 그대로 올리는 것은 1차 추천안이 아니다.
|
||||
- Workers 직접 운영을 목표로 한다면 TypeScript 기반 재구현 가능성을 우선 검토한다.
|
||||
- Go 서버를 유지하면서 Cloudflare를 앞단 proxy/Tunnel로 사용하는 방식은 단기 안전안으로 검토할 수 있다.
|
||||
- 최종 결정 전에는 Workers 지원 범위, secret 관리, 상태 저장소, JWT/RSA 구현 가능성을 작은 PoC로 확인해야 한다.
|
||||
|
||||
## 8. 우선 검토해야 할 항목
|
||||
|
||||
Cloudflare 기반 배포를 실제로 진행하려면 아래 항목을 먼저 확인해야 한다.
|
||||
|
||||
1. Gitea Actions 사용 가능 여부
|
||||
2. Gitea Runner 설치 여부
|
||||
3. Flutter Android 빌드가 runner에서 가능한지
|
||||
4. APK 서명 방식
|
||||
5. staging/prod 빌드 환경변수 분리 방식
|
||||
6. Cloudflare 배포 대상
|
||||
7. Cloudflare Workers 사용 여부
|
||||
8. APK 파일 저장 위치
|
||||
9. 사용자가 도메인 접속 시 바로 다운로드할지, 안내 페이지를 보여줄지
|
||||
10. production APK 배포 승인 절차
|
||||
11. `tdc114plus-auth`를 Workers로 직접 이식할지, 기존 서버 앞단 proxy로 둘지
|
||||
12. Cloudflare에서 `tdc114plus-auth` secret을 어떻게 관리할지
|
||||
13. Workers 환경에서 네이버웍스/Baron SSO 연동이 가능한지
|
||||
14. `tdc114plus-auth` 배포 rollback 방식
|
||||
15. 현재 Go 구현을 유지할지, TypeScript Workers로 재구현할지
|
||||
16. pending login 상태 저장소로 KV와 Durable Objects 중 무엇을 사용할지
|
||||
17. Workers 기반 PoC 범위를 어디까지 잡을지
|
||||
|
||||
## 9. 다음 작업 제안
|
||||
|
||||
다음 작업은 아래 순서로 진행하는 것이 안전하다.
|
||||
|
||||
1. 현재 문서에 Cloudflare 기반 앱 배포 방향을 반영한다.
|
||||
2. Gitea Actions 기준 Android APK 빌드 파이프라인 초안을 작성한다.
|
||||
3. staging/prod별 빌드 산출물 경로를 설계한다.
|
||||
4. Cloudflare Workers 또는 정적 페이지 배포 방식을 비교한다.
|
||||
5. `tdc114plus-auth`를 Cloudflare Workers로 이식할지, Workers proxy로 둘지 비교한다.
|
||||
6. `114.hmac.kr`, `114.brsw.kr` 도메인의 실제 연결 가능 여부를 확인한다.
|
||||
7. APK 서명/버전/릴리스 태그 정책을 정리한다.
|
||||
8. `tdc114plus-auth`의 Cloudflare secret, health check, rollback 정책을 정리한다.
|
||||
9. TypeScript Workers 기반 최소 PoC 범위를 정한다.
|
||||
10. PoC에서 `/health`, `/api/v1/auth/link/init`, `/api/v1/auth/link/poll`, `/api/v1/profile-image` 중 어떤 route를 먼저 검증할지 정한다.
|
||||
|
||||
## 10. 현재 결론
|
||||
|
||||
2026-07-20 회의 결과 기준으로, 신규앱 APK 배포는 아래 방향으로 정리한다.
|
||||
|
||||
- 소스 기준점은 Gitea 저장소다.
|
||||
- 자동화 실행 주체는 Gitea Actions다.
|
||||
- 빌드 결과물은 APK를 1차 대상으로 본다.
|
||||
- 앱 다운로드 제공은 Cloudflare를 사용한다.
|
||||
- `tdc114plus-auth` 중계 기능도 Cloudflare 관리 대상으로 본다.
|
||||
- 현재 Go 기반 `tdc114plus-auth`를 Workers에 그대로 올리는 것은 위험하므로, TypeScript Workers 재구현 또는 Workers proxy/Tunnel 방식을 비교한다.
|
||||
- `114.hmac.kr`은 staging 다운로드 경로로 본다.
|
||||
- `114.brsw.kr`은 production 다운로드 경로로 본다.
|
||||
- 사용자는 도메인 접속만으로 APK 다운로드 또는 다운로드 안내 페이지에 접근할 수 있어야 한다.
|
||||
|
||||
단, 앱 APK 배포와 `tdc114plus-auth` 중계 기능 배포는 Cloudflare 안에서 함께 관리하더라도 서로 다른 산출물과 검증 절차를 가진다.
|
||||
@@ -0,0 +1,202 @@
|
||||
# tdc114plus-auth 운영/커밋 정책
|
||||
|
||||
작성일: 2026-07-15
|
||||
상태: v1.0
|
||||
|
||||
목적: `tdc114plus-auth` 공식 저장소의 운영 기준, 커밋 경계, 검증 기준, `tdc114plus` 앱 저장소와의 교차 작업 규칙을 고정한다.
|
||||
|
||||
관련 저장소:
|
||||
|
||||
- 앱 저장소: `https://gitea.hmac.kr/kevin/tdc114plus.git`
|
||||
- auth 저장소: `https://gitea.hmac.kr/kevin/tdc114plus-auth.git`
|
||||
|
||||
관련 문서:
|
||||
|
||||
- `docs/00_policy_tdc114plus_auth_repo_2026-07-15.md`
|
||||
- `docs/00_guide_tdc114plus_auth_broker_redirect_flow_2026-07-15.md`
|
||||
- `docs/review_baron_sso_repo_commit_policy_2026-07-15.md`
|
||||
|
||||
## 1. 적용 범위
|
||||
|
||||
이 정책은 `tdc114plus-auth` 저장소의 아래 항목에 적용한다.
|
||||
|
||||
- 인증 중계 서버 본체 코드
|
||||
- Baron SSO headless/login 연동 코드
|
||||
- OIDC callback 처리
|
||||
- JWKS 제공
|
||||
- 앱 세션 발급/검증
|
||||
- org-context proxy
|
||||
- auth 서버 운영 문서
|
||||
- auth 서버 테스트와 실행 스크립트
|
||||
|
||||
## 2. 역할 정의
|
||||
|
||||
`tdc114plus-auth`는 아래 책임을 가진 독립 서버다.
|
||||
|
||||
- 앱의 `link/init`, `link/poll` 요청 수신
|
||||
- Baron SSO headless/link API 호출
|
||||
- `client_assertion` 생성
|
||||
- `login_challenge` 확보
|
||||
- `redirectTo` 추적
|
||||
- consent 처리
|
||||
- authorization code 수신
|
||||
- token exchange
|
||||
- 앱 전용 session token 발급
|
||||
- 조직도/직원 API proxy
|
||||
|
||||
즉, 이 저장소는 앱 UI 저장소가 아니라 보안/인증 책임을 가진 서버 저장소다.
|
||||
|
||||
## 3. 브랜치 정책
|
||||
|
||||
- `main`: 배포 가능한 기준 브랜치
|
||||
- `feature/<topic>`: 기능 추가
|
||||
- `fix/<topic>`: 버그 수정
|
||||
- `ops/<topic>`: 운영 설정, 실행 절차, 문서, 환경 가이드
|
||||
- `docs/<topic>`: 정책/가이드 문서 정리
|
||||
|
||||
예시:
|
||||
|
||||
- `feature/link-poll-session`
|
||||
- `feature/org-context-proxy`
|
||||
- `fix/oidc-callback-state`
|
||||
- `ops/staging-env-guide`
|
||||
|
||||
## 4. 커밋 경계 정책
|
||||
|
||||
한 커밋에는 아래 중 한 가지 성격만 담는다.
|
||||
|
||||
1. 인증 기능 변경
|
||||
2. 세션/보안 로직 변경
|
||||
3. proxy/API 동작 변경
|
||||
4. 테스트 추가/수정
|
||||
5. 운영 문서/실행 스크립트 변경
|
||||
|
||||
권장 원칙:
|
||||
|
||||
- 기능 변경과 포맷 변경을 섞지 않는다.
|
||||
- 리팩터링과 동작 변경을 가능하면 분리한다.
|
||||
- 문서 개정만 있을 때는 문서 커밋으로 따로 남긴다.
|
||||
- `.env.example` 변경은 실제 코드 변경과 강하게 연결될 때만 함께 커밋한다.
|
||||
|
||||
## 5. 커밋 메시지 규칙
|
||||
|
||||
권장 형식:
|
||||
|
||||
```text
|
||||
type(scope): summary
|
||||
```
|
||||
|
||||
예시:
|
||||
|
||||
```text
|
||||
feat(auth): add Baron link poll completion handling
|
||||
```
|
||||
|
||||
```text
|
||||
fix(callback): validate state before token exchange
|
||||
```
|
||||
|
||||
```text
|
||||
feat(proxy): add org-context bearer session guard
|
||||
```
|
||||
|
||||
```text
|
||||
docs(ops): update staging auth server startup guide
|
||||
```
|
||||
|
||||
## 6. 앱 저장소와의 교차 작업 규칙
|
||||
|
||||
앱 저장소와 auth 저장소를 함께 바꿔야 하는 경우에도 한 저장소에서 한 커밋만 만든다.
|
||||
|
||||
원칙:
|
||||
|
||||
- `tdc114plus` 앱 코드는 앱 저장소에서만 커밋한다.
|
||||
- `tdc114plus-auth` 서버 코드는 auth 저장소에서만 커밋한다.
|
||||
- 서로 연관된 변경이면 커밋 본문에 상대 저장소 커밋 해시를 남긴다.
|
||||
|
||||
예시:
|
||||
|
||||
```text
|
||||
Related-App-Commit: abc1234
|
||||
```
|
||||
|
||||
```text
|
||||
Related-Auth-Commit: def5678
|
||||
```
|
||||
|
||||
## 7. 보안/비밀정보 정책
|
||||
|
||||
절대 tracked commit에 넣지 않는 항목:
|
||||
|
||||
- private key
|
||||
- public/private key 실제 파일
|
||||
- 운영/개발 `.env`
|
||||
- client secret
|
||||
- session secret 실제 값
|
||||
- org-context key/secret 실제 값
|
||||
- 실사용 token
|
||||
- 승인 완료 callback query 원문 로그 전체
|
||||
|
||||
허용 항목:
|
||||
|
||||
- `.env.example`
|
||||
- 예시 placeholder
|
||||
- 비식별화된 로그 예시
|
||||
- 마스킹된 설정 예시
|
||||
|
||||
## 8. main 반영 전 최소 검증
|
||||
|
||||
`main` 반영 전 최소 확인 기준:
|
||||
|
||||
- 서버 기동 성공
|
||||
- `/health` 응답 확인
|
||||
- mock 또는 Baron mode 기준 핵심 auth 흐름 확인
|
||||
- 변경 범위에 맞는 test 실행
|
||||
- README 또는 관련 운영 문서 최신화 여부 확인
|
||||
|
||||
권장 검증 예:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
```bash
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
실제 Baron 연동 변경이면 아래 중 최소 하나를 남긴다.
|
||||
|
||||
- 수동 검증 기록
|
||||
- 로그 요약
|
||||
- 관련 문서 링크
|
||||
|
||||
## 9. 운영 문서 정책
|
||||
|
||||
아래 내용이 바뀌면 문서를 함께 갱신한다.
|
||||
|
||||
- callback URL
|
||||
- JWKS URI
|
||||
- base URL
|
||||
- proxy endpoint
|
||||
- env 변수 이름/역할
|
||||
- mock/baron 모드 동작 차이
|
||||
- 앱이 의존하는 응답 필드
|
||||
|
||||
문서 우선순위:
|
||||
|
||||
1. auth 저장소 `README.md`
|
||||
2. auth 저장소 `docs/`
|
||||
3. 앱 저장소의 auth 연동 문서
|
||||
|
||||
## 10. 배포/운영 해석
|
||||
|
||||
- `main`은 배포 후보 기준으로 유지한다.
|
||||
- 실험적 Baron 계약 검증은 feature 브랜치에서 먼저 진행한다.
|
||||
- 운영 반영 전에는 개발용 IP 기반 URL보다 도메인 기반 URL을 우선 문서화한다.
|
||||
- `114-auth.hmac.kr`에서 `114.hmac.kr`로 통합 논의가 생기면, 먼저 auth 저장소 문서를 갱신하고 이후 앱 저장소 설정을 맞춘다.
|
||||
|
||||
## 11. 현재 기준 결론
|
||||
|
||||
- `tdc114plus-auth`는 이미 공식 저장소가 존재하므로 생성 검토 단계는 종료됐다.
|
||||
- 이제 필요한 것은 저장소 추가가 아니라 운영/커밋 규칙의 고정이다.
|
||||
- 이후 auth 관련 서버 변경은 이 정책을 기준으로 저장소 분리, 커밋 분리, 검증 기록 분리를 유지한다.
|
||||
@@ -0,0 +1,215 @@
|
||||
# tdc114plus-auth 저장소 운영 정책
|
||||
|
||||
작성일: 2026-07-15
|
||||
상태: v1.1
|
||||
|
||||
목적: `tdc114plus-auth` 중계서버의 공식 Gitea 저장소 위치와 저장소 경계 운영 기준을 고정한다.
|
||||
|
||||
관련 문서:
|
||||
|
||||
- `docs/00_guide_tdc114plus_auth_broker_redirect_flow_2026-07-15.md`
|
||||
- `docs/00_policy_tdc114plus_development_2026-07-02.md`
|
||||
- `docs/review_baron_sso_repo_commit_policy_2026-07-15.md`
|
||||
|
||||
## 1. 결론
|
||||
|
||||
- `tdc114plus-auth`는 별도 Gitea 저장소로 관리한다.
|
||||
- `tdc114plus` 앱 저장소 안에 서버 본체 코드를 함께 두지 않는다.
|
||||
- `baron-sso` 저장소 안에도 흡수하지 않는다.
|
||||
- 최종 배포 기준에서도 `tdc114plus-auth`는 독립 배포 단위로 유지한다.
|
||||
- 다만 현재 로컬 개발에 쓰는 `baron-sso-tdc114plus-api` worktree는 최종 배포 필수 구성으로 보지 않는다.
|
||||
- 최종 운영 구조는 Baron SSO 원본 `staging` 연동 안정화 후 Baron SSO 원본 `production` 연동으로 승격하는 방향을 기본값으로 삼는다.
|
||||
|
||||
공식 저장소:
|
||||
|
||||
```text
|
||||
https://gitea.hmac.kr/kevin/tdc114plus-auth.git
|
||||
```
|
||||
|
||||
기본 브랜치:
|
||||
|
||||
```text
|
||||
main
|
||||
```
|
||||
|
||||
현재 확인 기준:
|
||||
|
||||
- 로컬 worktree: `/home/ubuntu/workspace/tdc114plus-auth`
|
||||
- 원격 `origin`: `https://gitea.hmac.kr/kevin/tdc114plus-auth.git`
|
||||
- 최근 확인 커밋: `2bc25d1 Add org context proxy`
|
||||
|
||||
## 2. 왜 별도 저장소가 필요한가
|
||||
|
||||
`tdc114plus-auth`는 단순 보조 스크립트가 아니라 독립 실행 서버다.
|
||||
|
||||
현재 책임:
|
||||
|
||||
- 앱의 `link/init`, `link/poll` 요청 수신
|
||||
- Baron SSO headless API 호출
|
||||
- `client_assertion` 생성
|
||||
- `login_challenge` 확보
|
||||
- `redirectTo` 추적
|
||||
- consent 처리
|
||||
- authorization code 수신
|
||||
- token exchange
|
||||
- 앱용 session token 발급
|
||||
- 직원/조직 API 중계
|
||||
|
||||
즉, 이 서버는 Flutter 앱과도 다르고 Baron SSO 본체와도 다른 별도 배포 단위다.
|
||||
|
||||
## 3. 저장소를 분리해야 하는 이유
|
||||
|
||||
### 3.1 보안 경계 분리
|
||||
|
||||
- 중계서버는 private key, session secret, OIDC 연계 설정, 외부 API credential을 다룬다.
|
||||
- 이런 값과 로직은 모바일 앱 저장소와 분리하는 편이 안전하다.
|
||||
- 앱 저장소에는 공개 가능한 설정과 실행 보조 스크립트만 남긴다.
|
||||
|
||||
### 3.2 커밋 경계 분리
|
||||
|
||||
- 앱 UI 변경과 중계서버 인증 로직 변경은 한 커밋으로 섞지 않는다.
|
||||
- 중계서버 장애 대응, 인증 예외 처리, API 응답 포맷 수정은 서버 커밋으로 따로 추적해야 한다.
|
||||
|
||||
### 3.3 배포 경계 분리
|
||||
|
||||
- 앱 배포와 중계서버 배포 시점은 다를 수 있다.
|
||||
- 서버는 긴급 핫픽스나 설정 변경이 앱 업데이트 없이도 필요할 수 있다.
|
||||
- 따라서 저장소와 배포 파이프라인도 분리하는 편이 낫다.
|
||||
|
||||
### 3.4 권한 분리
|
||||
|
||||
- 앱 개발자와 서버 운영자의 접근 권한이 달라질 수 있다.
|
||||
- 별도 저장소로 두면 읽기/쓰기 권한을 역할별로 나누기 쉽다.
|
||||
|
||||
### 3.5 운영 이력 분리
|
||||
|
||||
- 로그인 실패, consent 예외, token exchange 장애, org-context 중계 이슈는 앱 이슈와 성격이 다르다.
|
||||
- 서버 단위 이력, 태그, 릴리스 노트를 따로 관리해야 추적이 수월하다.
|
||||
|
||||
## 4. 저장소별 책임 경계
|
||||
|
||||
### 4.1 `tdc114plus` 저장소에 두는 것
|
||||
|
||||
- Flutter 앱 코드
|
||||
- 앱 정책 문서
|
||||
- 앱 테스트 코드
|
||||
- 앱 실행/검증 스크립트
|
||||
- `tdc114plus-auth` 실행 방법과 연동 절차 문서
|
||||
|
||||
### 4.2 `tdc114plus-auth` 저장소에 두는 것
|
||||
|
||||
- HTTP server 본체
|
||||
- auth handler
|
||||
- Baron SSO client
|
||||
- consent 처리 로직
|
||||
- token exchange 로직
|
||||
- app session 발급/검증 로직
|
||||
- org-context proxy 또는 중계 로직
|
||||
- 서버 전용 테스트
|
||||
- 서버 전용 운영 문서
|
||||
|
||||
### 4.3 `baron-sso` 저장소에 두는 것
|
||||
|
||||
- Baron SSO 공식 backend/orgFront/userFront 코드
|
||||
- `tdc114plus-auth`가 소비해야 하는 공식 API 변경
|
||||
- Baron SSO 본체 정책 변경
|
||||
|
||||
## 5. 금지 원칙
|
||||
|
||||
- `tdc114plus-auth` 서버 코드를 `tdc114plus` 앱 저장소로 복사 반입하지 않는다.
|
||||
- private key, secret, 운영용 credential을 tracked 파일로 커밋하지 않는다.
|
||||
- 앱 저장소와 중계서버 저장소에 같은 서버 코드를 중복 보관하지 않는다.
|
||||
- 앱 커밋 하나에 서버 본체 코드 변경을 함께 넣지 않는다.
|
||||
|
||||
## 6. 브랜치 및 커밋 정책
|
||||
|
||||
브랜치 예시:
|
||||
|
||||
- `main`
|
||||
- `feature/link-init-poll`
|
||||
- `feature/oidc-callback`
|
||||
- `fix/consent-redirect`
|
||||
- `ops/staging-config`
|
||||
|
||||
커밋 예시:
|
||||
|
||||
```text
|
||||
feat(auth): add Baron headless link init flow
|
||||
```
|
||||
|
||||
```text
|
||||
fix(session): handle expired poll response consistently
|
||||
```
|
||||
|
||||
```text
|
||||
ops(staging): adjust auth broker env defaults
|
||||
```
|
||||
|
||||
앱 저장소와 함께 작업한 경우:
|
||||
|
||||
- 앱 커밋 본문에 `Related-Auth-Commit: <hash>` 기록
|
||||
- auth 저장소 커밋 본문에 `Related-App-Commit: <hash>` 기록
|
||||
|
||||
## 7. 현재 저장소 기준 최소 유지 항목
|
||||
|
||||
현재 저장소에서 유지해야 할 기본 항목:
|
||||
|
||||
- `README.md`
|
||||
- `.gitignore`
|
||||
- `.env.example`
|
||||
- `cmd/server/` 또는 동등한 진입점
|
||||
- `internal/` 또는 `pkg/` 구조
|
||||
- `secrets/`는 예시 파일만 tracked, 실제 키는 비추적
|
||||
- 배포/실행 방법 문서
|
||||
|
||||
권장:
|
||||
|
||||
- `docs/`
|
||||
- `Makefile` 또는 표준 실행 스크립트
|
||||
- health check endpoint
|
||||
- staging/prod 설정 가이드
|
||||
|
||||
현재 확인된 기본 구성:
|
||||
|
||||
- `cmd/server/`
|
||||
- `docs/`
|
||||
- `.env.example`
|
||||
- `.gitignore`
|
||||
- `go.mod`
|
||||
- `README.md`
|
||||
|
||||
## 8. 현재 앱 저장소에서의 역할
|
||||
|
||||
현재 앱 저장소는 `tdc114plus-auth`를 직접 소유하지 않고 아래 역할만 가진다.
|
||||
|
||||
- 중계서버 필요성 문서화
|
||||
- 중계서버 실행 보조 스크립트 유지
|
||||
- 연동 주소, 포트, 점검 절차 문서화
|
||||
- 앱과 중계서버 간 계약 확인
|
||||
|
||||
예를 들어 `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`는 별도 저장소가 필요한 독립 서비스다.
|
||||
- 이 저장소 분리는 선택이 아니라 사실상 운영 안정성을 위한 기본 구조로 보는 편이 맞다.
|
||||
- 앱 저장소에는 연동 문서와 실행 보조 스크립트만 두고, 서버 코드/배포/보안 자산은 `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,46 @@
|
||||
# tdc114plus 문서 색인
|
||||
|
||||
이 문서는 `docs` 바로 아래에 있는 Markdown 파일의 첫 줄 제목을 모아, 개발자가 각 문서의 내용을 빠르게 파악할 수 있도록 정리한 색인입니다.
|
||||
|
||||
파일명 규칙:
|
||||
|
||||
- `00_`: 개발 진행 전 반드시 먼저 검토해야 하는 핵심 문서
|
||||
- `policy_`: 정책/원칙 문서
|
||||
- `contract_`: API 계약 문서
|
||||
- `checklist_`: 점검 체크리스트 문서
|
||||
- `runtime_`: 실행/운영 절차 문서
|
||||
- `dev_env_`: 개발환경 구성 문서
|
||||
- `guide_`: 가이드/참고 문서
|
||||
- `scenario_`: 시나리오 문서
|
||||
- `review_`: 검토 결과 문서
|
||||
|
||||
| 파일 | 첫 줄 제목 |
|
||||
| --- | --- |
|
||||
| [policy_android_app_install_execution_2026-07-06.md](policy_android_app_install_execution_2026-07-06.md) | Android 앱 설치/실행 재발 방지 정책 |
|
||||
| [scenario_android_emulator_device_integration_test_2026-07-03.md](scenario_android_emulator_device_integration_test_2026-07-03.md) | Android target 통합테스트 진행 시나리오 |
|
||||
| [policy_android_studio_wsl_adb_2026-07-03.md](policy_android_studio_wsl_adb_2026-07-03.md) | Android Studio / WSL ADB 연동 재발 방지 정책 |
|
||||
| [guide_baron_sso_reference_source_2026-07-02.md](guide_baron_sso_reference_source_2026-07-02.md) | tdc114plus Baron SSO 참조 소스 기준 |
|
||||
| [scenario_staging_baron_sso_login_verification_2026-07-06.md](scenario_staging_baron_sso_login_verification_2026-07-06.md) | staging Baron SSO 승인 로그인 검증 시나리오 |
|
||||
| [00_policy_tdc114plus_decoupled_api_migration_2026-07-07.md](00_policy_tdc114plus_decoupled_api_migration_2026-07-07.md) | tdc114plus 분리형 API 전환 정책 |
|
||||
| [00_policy_tdc114plus_auth_repo_2026-07-15.md](00_policy_tdc114plus_auth_repo_2026-07-15.md) | tdc114plus-auth 저장소 운영 정책 |
|
||||
| [00_policy_tdc114plus_auth_operations_2026-07-15.md](00_policy_tdc114plus_auth_operations_2026-07-15.md) | tdc114plus-auth 운영/커밋 정책 |
|
||||
| [00_guide_tdc114plus_auth_broker_redirect_flow_2026-07-15.md](00_guide_tdc114plus_auth_broker_redirect_flow_2026-07-15.md) | TDC114PLUS / tdc114plus-auth / Baron SSO Redirect Flow |
|
||||
| [00_policy_tdc114plus_screen_feature_2026-07-07.md](00_policy_tdc114plus_screen_feature_2026-07-07.md) | tdc114plus 화면별/기능별 정책 |
|
||||
| [00_guide_tdc114plus_external_api_usage_2026-07-03.md](00_guide_tdc114plus_external_api_usage_2026-07-03.md) | tdc114plus 외부 API 사용 및 앱 내 활용 정리 |
|
||||
| [00_guide_tdc114plus_swagger_feature_mapping_2026-07-07.md](00_guide_tdc114plus_swagger_feature_mapping_2026-07-07.md) | tdc114plus Swagger API 목록 및 Feature 매핑 |
|
||||
| [00_contract_tdc114plus_api_2026-07-02.md](00_contract_tdc114plus_api_2026-07-02.md) | tdc114plus API 계약 |
|
||||
| [guide_tdc114plus_development_decision_brief_2026-07-01.md](guide_tdc114plus_development_decision_brief_2026-07-01.md) | tdc114plus(가칭) 신규 앱 개발 판단 문서 |
|
||||
| [dev_env_tdc114plus_setup_plan_2026-07-02.md](dev_env_tdc114plus_setup_plan_2026-07-02.md) | tdc114plus 개발환경 구성 작업순서 |
|
||||
| [review_tdc114plus_policy_document_2026-07-07.md](review_tdc114plus_policy_document_2026-07-07.md) | tdc114plus 정책 문서 개편 검토 |
|
||||
| [00_policy_tdc114plus_development_2026-07-02.md](00_policy_tdc114plus_development_2026-07-02.md) | tdc114plus 개발 정책 |
|
||||
| [guide_tdc114plus_script_automation_plan_2026-07-02.md](guide_tdc114plus_script_automation_plan_2026-07-02.md) | tdc114plus 테스트 자동화 스크립트 계획 |
|
||||
| [00_policy_tdc114plus_testing_2026-07-02.md](00_policy_tdc114plus_testing_2026-07-02.md) | tdc114plus 테스트 정책 |
|
||||
| [00_guide_tdc114plus_work_progress_timetable_2026-07-02.md](00_guide_tdc114plus_work_progress_timetable_2026-07-02.md) | tdc114plus 작업진행 절차 및 타임테이블 |
|
||||
| [checklist_android_device_startup_2026-07-09.md](checklist_android_device_startup_2026-07-09.md) | 공기계 USB 테스트 시작 체크리스트 |
|
||||
|
||||
하위 디렉터리의 Markdown 파일은 이 목록에서 제외했습니다.
|
||||
|
||||
참고:
|
||||
|
||||
- Android Studio / WSL ADB 연동 상세 타임테이블은 `docs/troubleshooting/android-studio-wsl-adb-timetable-260703.md`를 참고한다.
|
||||
- 반복 지연 대응 정리는 `docs/troubleshooting/flutter-docker-repeated-delay-countermeasures-2026-07-03.md`를 참고한다.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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,408 @@
|
||||
# 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 기준 로그인 막힘의 핵심은 `앱이 요청을 못 보내는 문제`가 아니라 `요청은 정상인데 실제 문자 링크가 사용자에게 도착하지 않는 문제`다.
|
||||
|
||||
## 2026-07-20 오후 최종 안정화 결과
|
||||
|
||||
오전 인계 시점 이후 Baron SSO 문자 링크 흐름과 `tdc114plus-auth` 중계 흐름을 재점검했고, 최종적으로 실기기 기준 정상 진입을 확인했다.
|
||||
|
||||
최종 확인된 내용:
|
||||
|
||||
- 실기기에서 로그인 링크 발송 성공
|
||||
- 문자 링크 수신 및 승인 성공
|
||||
- 승인 후 앱 진입 성공
|
||||
- 직원검색 목록 표시 정상
|
||||
- 조직도 표시 정상
|
||||
- 즐겨찾기 표시 정상
|
||||
- 직원 상세 진입 정상
|
||||
- 화면 전환 후 목록/이미지 재표시 정상
|
||||
|
||||
## 2026-07-20 프로필 사진 최종 검증 결과
|
||||
|
||||
프로필 사진 우선순위는 아래 정책으로 확정하고 실기기에서 확인했다.
|
||||
|
||||
1. `NAVER_WORKS`
|
||||
2. `BARON_UUID_R2`
|
||||
3. `DEFAULT`
|
||||
|
||||
최종 확인된 대표 사용자:
|
||||
|
||||
| 우선순위 | source | 사용자 | 확인 결과 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1순위 | `NAVER_WORKS` | 한치영 / 이태훈 | 네이버웍스 프로필 사진 표시 정상 |
|
||||
| 2순위 | `BARON_UUID_R2` | 문형석 등 | UUID 파일명 기반 이미지 표시 정상 |
|
||||
| 3순위 | `DEFAULT` | 강버들 | 앱 기본 아바타 fallback 정상 |
|
||||
|
||||
중요 정리:
|
||||
|
||||
- 네이버웍스 사진이 있는 사용자는 1순위로 네이버웍스 사진을 사용한다.
|
||||
- 네이버웍스 사진이 없고 UUID 이미지가 있으면 `https://baroncs.co.kr/employee_img/{uuid}.jpg` 경로를 사용한다.
|
||||
- 둘 다 없으면 앱 기본 아바타로 자연스럽게 fallback 된다.
|
||||
|
||||
## 2026-07-20 코드/문서 커밋 및 push 결과
|
||||
|
||||
오늘 안정화된 변경은 두 저장소에 로컬 커밋 후 Gitea `origin/main`으로 push 완료했다.
|
||||
|
||||
`tdc114plus`:
|
||||
|
||||
- `5d3eee7 Stabilize auth flow and profile images`
|
||||
- `cb36612 Move external secrets behind auth broker`
|
||||
- `a0d393e docs: expand auth deployment guidance`
|
||||
- `a869231 docs: clarify auth deployment boundaries`
|
||||
|
||||
`tdc114plus-auth`:
|
||||
|
||||
- `4e97ef8 Stabilize auth broker and profile image proxy`
|
||||
- `0e448bd Document broker-managed external secrets`
|
||||
|
||||
정리:
|
||||
|
||||
- 앱 쪽 Baron API Key 직접 관리 제거 완료
|
||||
- 외부 secret은 `tdc114plus-auth` 중계서버에서 관리하는 방향으로 정리
|
||||
- PostgreSQL 프로필 이미지 매핑안은 폐기
|
||||
- `tdc114plus-auth`의 PostgreSQL 잔여 파일(`go.sum`, `db/profile_image_mapping.sql`, 빈 `db/`) 삭제 완료
|
||||
|
||||
## 2026-07-20 배포 회의 문서 정리 결과
|
||||
|
||||
배포 준비 회의용 문서를 정리했다.
|
||||
|
||||
주요 문서:
|
||||
|
||||
- `docs/00_guide_tdc114plus_android_release_meeting_2026-07-20.md`
|
||||
- `docs/00_meeting_onepage_tdc114plus_release_2026-07-20.md`
|
||||
- `docs/00_meeting_onepage_tdc114plus_release_2026-07-20.print.html`
|
||||
- `docs/00_meeting_onepage_tdc114plus_release_2026-07-20.pdf`
|
||||
|
||||
문서 기준 현재 방향:
|
||||
|
||||
- 신규앱은 `staging -> 내부 검증 -> production` 순서로 배포한다.
|
||||
- 최종 배포 직접 대상은 `tdc114plus` 앱과 `tdc114plus-auth` 서버다.
|
||||
- `tdc114plus-auth`는 Baron SSO 본체에 흡수하지 않고 독립 서비스로 유지한다.
|
||||
- 초기에는 Baron 계열 운영 인프라 안의 독립 서비스로 배치할 수 있다.
|
||||
- 서버 배포 전략은 rollback이 쉬운 블루/그린을 1순위로 본다.
|
||||
|
||||
## 다음 작업 시작 시 우선 순서
|
||||
|
||||
1. 업무시작 기동 전에 두 저장소 `git status` 확인
|
||||
2. `tdc114plus-auth` 5001 또는 staging auth endpoint health 확인
|
||||
3. 실기기 `adb reverse --list` 확인
|
||||
4. 로그인 링크 발송, 문자 수신, 승인 후 앱 진입 확인
|
||||
5. 직원검색/조직도/즐겨찾기/상세 화면 회귀 확인
|
||||
6. 프로필 사진 1순위/2순위/3순위 대표 사용자 재확인
|
||||
7. 배포 준비 단계로 이동하기 전, 회의 문서와 실제 서버 배포 체크리스트 정합성 확인
|
||||
|
||||
## 퇴근 전 종료 기동 점검 결과
|
||||
|
||||
종료 스크립트 점검 결과:
|
||||
|
||||
- `bash -n scripts/shutdown.sh` 통과
|
||||
- `./scripts/shutdown.sh --dry-run` 통과
|
||||
- dry-run 기준 종료 대상은 helper 프로세스, 5001 listener, ADB disconnect 조건 확인, Baron SSO compose 로그 수집, compose down, 권한 복구 순서다.
|
||||
|
||||
주의:
|
||||
|
||||
- `scripts/shutdown.sh --auto`는 `/home/ubuntu/workspace/baron-sso-tdc114plus-api` Docker compose down을 포함한다.
|
||||
- 물리 실기기 USB reverse는 `TDC114_ADB_CONNECT_ADDRESS`가 없으면 임의 disconnect하지 않는다.
|
||||
- 다음날 업무시작 시 `docs/checklist_morning_startup_runtime_2026-07-03.md` 기준으로 재기동한다.
|
||||
@@ -0,0 +1,181 @@
|
||||
# 2026-07-21 업무 인계 메모
|
||||
|
||||
작성 시각: 2026-07-21 KST
|
||||
|
||||
## 오늘 핵심 결론
|
||||
|
||||
- 전일 종료 후 금일 업무시작 기동을 수행했다.
|
||||
- Android 실기기/ADB reverse는 오전 중 재설정하여 정상 상태를 확인했다.
|
||||
- Baron/Ory runtime, `tdc114plus-auth` 5001, API smoke는 정상 확인했다.
|
||||
- 신규앱 실기기 로그상 로그인, 앱 세션 발급, 직원검색, 조직도, 프로필 이미지 호출 흐름이 정상 기록되었다.
|
||||
- 배포 회의 결과에 따라 `tdc114plus`와 `tdc114plus-auth` 모두 Cloudflare 관리 체계로 가져가는 방향을 문서화했다.
|
||||
|
||||
## 1. 금일 업무시작 기동 결과
|
||||
|
||||
업무시작 시 아래 순서로 확인했다.
|
||||
|
||||
1. 전일 인계 문서 확인
|
||||
2. `startup.sh` 문법 및 dry-run 확인
|
||||
3. Windows ADB 서버 및 실기기 상태 확인
|
||||
4. 실기기 `adb reverse tcp:5000`, `tcp:5001` 재설정
|
||||
5. Baron/Ory runtime 기동
|
||||
6. `tdc114plus-auth` 재기동
|
||||
7. `check-baron-api-env.sh` 확인
|
||||
8. `api-smoke.sh` 확인
|
||||
|
||||
정상 확인 결과:
|
||||
|
||||
- `tdc114plus`, `tdc114plus-auth` 작업트리 깨끗한 상태에서 시작
|
||||
- 실기기 `R5CT42QTCNX` 연결 확인
|
||||
- `adb reverse tcp:5000 tcp:5000` 설정
|
||||
- `adb reverse tcp:5001 tcp:5001` 설정
|
||||
- Baron/Ory 컨테이너 health 정상
|
||||
- `tdc114plus-auth` health 정상
|
||||
|
||||
`tdc114plus-auth` health 응답:
|
||||
|
||||
```json
|
||||
{"jwks":"ok","provider":"baron","status":"ok"}
|
||||
```
|
||||
|
||||
## 2. 업무시작 중 발생한 이슈
|
||||
|
||||
### 2.1 Android preflight 첫 실패
|
||||
|
||||
처음 `startup.sh --dry-run` 실행 시 WSL에서 Windows ADB 서버 `172.21.128.1:5037` 접근이 막혀 실패했다.
|
||||
|
||||
이후 승인 권한으로 다시 확인했으나 `Connection reset by peer`가 발생했다.
|
||||
|
||||
처리:
|
||||
|
||||
- Windows PowerShell에서 `adb.exe devices` 확인
|
||||
- 실기기 `R5CT42QTCNX device` 상태 확인
|
||||
- 오프라인 emulator가 여러 개 있었으나 실기기 기준으로 `-s R5CT42QTCNX`를 지정해 reverse 재설정
|
||||
|
||||
결과:
|
||||
|
||||
```text
|
||||
UsbFfs tcp:5000 tcp:5000
|
||||
UsbFfs tcp:5001 tcp:5001
|
||||
```
|
||||
|
||||
### 2.2 `tdc114plus-auth` 첫 startup 실패
|
||||
|
||||
`startup.sh --auto` 중 `tdc114plus-auth` health 대기에서 한 번 실패했다.
|
||||
|
||||
로그상 `tdc114plus-auth listening on :5001`까지 찍힌 뒤 프로세스가 종료되었고, 이후 `start-auth-server.sh --restart`로 재기동했다.
|
||||
|
||||
결과:
|
||||
|
||||
- `tdc114plus-auth ready on :5001`
|
||||
- `/health` 정상
|
||||
|
||||
판단:
|
||||
|
||||
- 소스 오류보다는 첫 기동/컴파일/프로세스 타이밍 문제에 가까웠다.
|
||||
- 재기동 후 정상 동작했다.
|
||||
|
||||
## 3. 실기기 앱 동작 확인 기록
|
||||
|
||||
금일 로그 기준 신규앱은 실제로 아래 흐름을 정상 수행했다.
|
||||
|
||||
- `POST /api/v1/auth/link/init`
|
||||
- `POST /api/v1/auth/link/poll`
|
||||
- Baron SSO 승인 완료
|
||||
- 앱 세션 발급
|
||||
- 사용자 메타데이터 보강
|
||||
- org-context 조회
|
||||
- profile-image 조회
|
||||
|
||||
로그상 확인된 사용자:
|
||||
|
||||
- 문형석
|
||||
- `tenant_slug=is-3`
|
||||
- `tenant_name=IS3`
|
||||
|
||||
프로필 이미지 source 확인:
|
||||
|
||||
- `NAVER_WORKS`
|
||||
- `BARON_UUID_R2`
|
||||
|
||||
즉 서버 로그 기준으로는 로그인, 직원검색, 조직도, 프로필 이미지 흐름이 정상 동작했다.
|
||||
|
||||
## 4. 실기기 USB 연결 관련 정리
|
||||
|
||||
금일 확인한 중요한 정리:
|
||||
|
||||
- 실제 운영/배포 APK가 동작하는 데 USB 연결은 필요 없다.
|
||||
- USB가 필요한 이유는 현재 개발용 APK가 `127.0.0.1:5000`, `127.0.0.1:5001`을 바라보기 때문이다.
|
||||
- 로컬 테스트에서는 실기기 앱 호출을 개발 PC 로컬 서버로 보내기 위해 `adb reverse`가 필요하다.
|
||||
- Cloudflare/staging/production 도메인을 바라보는 APK는 USB 없이 동작해야 한다.
|
||||
|
||||
정리:
|
||||
|
||||
```text
|
||||
개발 로컬 테스트: USB + adb reverse 필요
|
||||
실제 배포 APK: USB 불필요
|
||||
Cloudflare 도메인 기반 APK: USB 불필요
|
||||
```
|
||||
|
||||
## 5. 신규앱 배포 회의 결과 정리
|
||||
|
||||
2026-07-20 팀장 회의 결과를 금일 문서로 정리했다.
|
||||
|
||||
문서:
|
||||
|
||||
- `docs/00_meeting_result_tdc114plus_release_2026-07-20.md`
|
||||
|
||||
팀장 확인 완료 기준 이해 내용:
|
||||
|
||||
1. 신규앱 `tdc114plus`와 중계서버 `tdc114plus-auth` 모두 Cloudflare 관리 체계 안에서 운영한다.
|
||||
2. `tdc114plus`는 Gitea에 코드가 올라가면 Gitea Actions로 APK를 자동 빌드하고, 결과물을 Cloudflare에 배포한다.
|
||||
3. `114.hmac.kr`은 staging용 앱 다운로드 및 검증 경로로 사용한다.
|
||||
4. `114.brsw.kr`은 production용 앱 다운로드 경로로 사용한다.
|
||||
5. 사용자는 도메인 접속만으로 APK 다운로드 또는 설치 안내 페이지에 접근한다.
|
||||
6. `tdc114plus-auth`도 Cloudflare Workers 쪽에서 운영하는 방향으로 검토한다.
|
||||
7. 현재 `tdc114plus-auth`는 Go 기반 서버이므로 Workers에 그대로 올릴 수 있는지 기술 검토가 필요하다.
|
||||
8. Go 기반 운영이 어렵거나 안정성이 낮다면 TypeScript 등 Workers 지원 언어로 재구현을 검토한다.
|
||||
9. 검토 결과를 기준으로 `Go 유지 + Cloudflare proxy/Tunnel 방식`과 `Workers 지원 언어 재구현 방식` 중 안정적인 방향을 비교 제안한다.
|
||||
|
||||
## 6. Cloudflare Workers 검토 기준
|
||||
|
||||
현재 판단:
|
||||
|
||||
- Go 기반 `tdc114plus-auth`를 Cloudflare Workers에 그대로 올리는 것은 어렵거나 위험할 수 있다.
|
||||
- Workers 직접 운영이 목표라면 TypeScript Workers 재구현을 우선 검토한다.
|
||||
- 단기 안전안으로 기존 Go 서버 유지 + Cloudflare Workers/Tunnel/proxy 앞단 구성도 비교한다.
|
||||
|
||||
검토해야 할 항목:
|
||||
|
||||
- Workers 지원 언어와 런타임 제약
|
||||
- Baron SSO 외부 API 호출 가능 여부
|
||||
- NAVER WORKS OAuth/JWT/RSA 처리 가능 여부
|
||||
- 앱 세션 JWT 발급 방식
|
||||
- pending login 상태 저장소 선택
|
||||
- Cloudflare Secrets 관리
|
||||
- health check와 rollback 방식
|
||||
|
||||
## 7. 다음 작업 시작 시 우선 순서
|
||||
|
||||
1. 두 저장소 `git status` 확인
|
||||
2. 오늘 작성한 회의결과 문서가 원격에 push되었는지 확인
|
||||
3. Cloudflare Workers 지원 범위 공식 문서 확인
|
||||
4. `tdc114plus-auth` 기능별 Workers 이식 가능성 표를 더 세분화
|
||||
5. TypeScript Workers PoC 범위 확정
|
||||
6. Gitea Actions 기반 APK 빌드/Cloudflare 배포 파이프라인 초안 작성
|
||||
7. `114.hmac.kr`, `114.brsw.kr`의 staging/production 경로 정책 정리
|
||||
|
||||
## 8. 퇴근 전 종료 기동 대상
|
||||
|
||||
종료 전 확인할 것:
|
||||
|
||||
- `tdc114plus` 변경분 커밋/push
|
||||
- `tdc114plus-auth` 변경분 여부 확인
|
||||
- `scripts/shutdown.sh` 문법 확인
|
||||
- `scripts/shutdown.sh --dry-run` 확인
|
||||
- 실제 종료 기동 `scripts/shutdown.sh --auto`
|
||||
|
||||
주의:
|
||||
|
||||
- 종료 스크립트는 `baron-sso-tdc114plus-api` Docker compose down을 포함한다.
|
||||
- 실기기 USB reverse는 명시적 Android connect address가 없으면 임의 disconnect하지 않는다.
|
||||
@@ -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
|
||||
```
|
||||
+17
-2
@@ -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. 비고
|
||||
|
||||
- `수행팀`, `업무수행자`, `협업팀`은 실제 대시보드 선택값에 맞춰 마지막에만 조정하면 된다.
|
||||
- 프로젝트명을 조금 더 실무형으로 보이게 하려면 `구축`, 조금 더 계속과제 느낌으로 보이게 하려면 `고도화`를 쓰면 된다.
|
||||
- 핵심 추진 프로젝트 체크 여부는 팀 운영 방식에 따라 다르지만, 현재 성격상 체크하는 편이 자연스럽다.
|
||||
+1
-1
@@ -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`에 기록한다.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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
+1863
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,
|
||||
|
@@ -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 대비 운영 복잡도는 확실히 낮아질 가능성이 높다.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user