diff --git a/.env.example b/.env.example index f3c62e9..ba5d484 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,25 @@ KAKAO_REST_API_KEY= KAKAO_JAVASCRIPT_KEY= KAKAO_NATIVE_APP_KEY= +# Optional OAuth login client credentials. +# Redirect URI template: {backend-or-proxy-origin}/login/oauth2/code/{provider} +# Example local callbacks: +# - http://127.0.0.1:8080/login/oauth2/code/google +# - http://127.0.0.1:8080/login/oauth2/code/naver +# - http://127.0.0.1:8080/login/oauth2/code/kakao +# For a public tunnel proxy demo, set this to the public frontend origin. +# Example callback: https://example.ngrok-free.dev/login/oauth2/code/google +APP_AUTH_OAUTH_REDIRECT_BASE_URL= +OAUTH_GOOGLE_CLIENT_ID= +OAUTH_GOOGLE_CLIENT_SECRET= +OAUTH_NAVER_CLIENT_ID= +OAUTH_NAVER_CLIENT_SECRET= +OAUTH_KAKAO_CLIENT_ID= +# Kakao client secret is optional unless the Kakao app has client secret verification enabled. +OAUTH_KAKAO_CLIENT_SECRET= +APP_AUTH_OAUTH_SUCCESS_REDIRECT=http://127.0.0.1:5173/dashboard +APP_AUTH_OAUTH_FAILURE_REDIRECT=http://127.0.0.1:5173/auth/login?oauth=failed + # --- 적재/갱신 주기 --- # 실거래(MOLIT): 월 단위 발행 + 최근 달 지각신고 → 하루 1회 갱신이면 충분. # 기동 시 realestate-seed가 1회 멱등 적재, realestate-refresh가 현재월 재수집 + diff --git a/.gitignore b/.gitignore index 7123a3f..7da668d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ artifacts/ output/ .codex/ .playwright-mcp/ +.playwright-cli/ +.run-logs/ /indicators-domestic-snapshot.md /dashboard-feed-title-snapshot.md /complex-trade-history-card.png diff --git a/backend/pom.xml b/backend/pom.xml index 964df65..e358748 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -42,6 +42,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-client + org.flywaydb flyway-core diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/AppOAuth2UserService.java b/backend/src/main/java/com/youbuyfirst/backend/auth/AppOAuth2UserService.java new file mode 100644 index 0000000..0768e32 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/AppOAuth2UserService.java @@ -0,0 +1,28 @@ +package com.youbuyfirst.backend.auth; + +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +public class AppOAuth2UserService implements OAuth2UserService { + + private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + private final OAuthAccountService oauthAccountService; + + public AppOAuth2UserService(OAuthAccountService oauthAccountService) { + this.oauthAccountService = oauthAccountService; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oauthUser = delegate.loadUser(userRequest); + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + OAuthProviderProfile profile = OAuthProviderProfile.from(registrationId, oauthUser.getAttributes()); + AppUser user = oauthAccountService.findOrCreateUser(profile); + return AppUserPrincipal.oauth(user, oauthUser.getAttributes()); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/AppOidcUserService.java b/backend/src/main/java/com/youbuyfirst/backend/auth/AppOidcUserService.java new file mode 100644 index 0000000..5320565 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/AppOidcUserService.java @@ -0,0 +1,43 @@ +package com.youbuyfirst.backend.auth; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; + +@Service +public class AppOidcUserService implements OAuth2UserService { + + private final OAuth2UserService delegate; + private final OAuthAccountService oauthAccountService; + + @Autowired + public AppOidcUserService(OAuthAccountService oauthAccountService) { + this(oauthAccountService, new OidcUserService()); + } + + AppOidcUserService( + OAuthAccountService oauthAccountService, + OAuth2UserService delegate + ) { + this.oauthAccountService = oauthAccountService; + this.delegate = delegate; + } + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + OidcUser oidcUser = delegate.loadUser(userRequest); + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + OAuthProviderProfile profile = OAuthProviderProfile.from(registrationId, oidcUser.getAttributes()); + AppUser user = oauthAccountService.findOrCreateUser(profile); + return AppUserPrincipal.oidc( + user, + oidcUser.getAttributes(), + oidcUser.getIdToken(), + oidcUser.getUserInfo() + ); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/AppUser.java b/backend/src/main/java/com/youbuyfirst/backend/auth/AppUser.java index cccc59f..30f98ca 100644 --- a/backend/src/main/java/com/youbuyfirst/backend/auth/AppUser.java +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/AppUser.java @@ -85,6 +85,28 @@ public static AppUser local(String username, String email, String displayName, S ); } + public static AppUser oauth( + String username, + String email, + String displayName, + String passwordHash, + String authProvider, + Instant now + ) { + return new AppUser( + UUID.randomUUID().toString(), + username, + email, + displayName, + passwordHash, + authProvider, + "USER", + "active", + now, + now + ); + } + public void markSeen(Instant now) { this.lastSeenAt = now; } diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/AppUserPrincipal.java b/backend/src/main/java/com/youbuyfirst/backend/auth/AppUserPrincipal.java index 571a771..caf6960 100644 --- a/backend/src/main/java/com/youbuyfirst/backend/auth/AppUserPrincipal.java +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/AppUserPrincipal.java @@ -3,11 +3,17 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.OAuth2User; import java.util.Collection; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; -public class AppUserPrincipal implements UserDetails { +public class AppUserPrincipal implements UserDetails, OAuth2User, OidcUser { private final String userId; private final String username; @@ -16,8 +22,16 @@ public class AppUserPrincipal implements UserDetails { private final String passwordHash; private final String status; private final List authorities; - - private AppUserPrincipal(AppUser user) { + private final Map attributes; + private final OidcIdToken idToken; + private final OidcUserInfo userInfo; + + private AppUserPrincipal( + AppUser user, + Map attributes, + OidcIdToken idToken, + OidcUserInfo userInfo + ) { this.userId = user.getId(); this.username = user.getUsername(); this.email = user.getEmail(); @@ -25,10 +39,26 @@ private AppUserPrincipal(AppUser user) { this.passwordHash = user.getPasswordHash(); this.status = user.getStatus(); this.authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole())); + this.attributes = copyNonNull(attributes); + this.idToken = idToken; + this.userInfo = userInfo; } public static AppUserPrincipal from(AppUser user) { - return new AppUserPrincipal(user); + return new AppUserPrincipal(user, Map.of(), null, null); + } + + public static AppUserPrincipal oauth(AppUser user, Map attributes) { + return new AppUserPrincipal(user, attributes, null, null); + } + + public static AppUserPrincipal oidc( + AppUser user, + Map attributes, + OidcIdToken idToken, + OidcUserInfo userInfo + ) { + return new AppUserPrincipal(user, attributes, idToken, userInfo); } public String getUserId() { @@ -52,6 +82,31 @@ public Collection getAuthorities() { return authorities; } + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Map getClaims() { + return attributes; + } + + @Override + public OidcUserInfo getUserInfo() { + return userInfo; + } + + @Override + public OidcIdToken getIdToken() { + return idToken; + } + + @Override + public String getName() { + return userId; + } + @Override public String getPassword() { return passwordHash; @@ -81,4 +136,13 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { return "active".equals(status); } + + private static Map copyNonNull(Map attributes) { + if (attributes == null || attributes.isEmpty()) { + return Map.of(); + } + return attributes.entrySet().stream() + .filter(entry -> entry.getKey() != null && entry.getValue() != null) + .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue)); + } } diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/AuthController.java b/backend/src/main/java/com/youbuyfirst/backend/auth/AuthController.java index 04d7eeb..bed68f5 100644 --- a/backend/src/main/java/com/youbuyfirst/backend/auth/AuthController.java +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/AuthController.java @@ -2,6 +2,7 @@ import com.youbuyfirst.backend.auth.dto.CurrentUserResponse; import com.youbuyfirst.backend.auth.dto.LoginRequest; +import com.youbuyfirst.backend.auth.dto.OAuthProviderStatusResponse; import com.youbuyfirst.backend.auth.dto.RegisterRequest; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; @@ -25,16 +26,24 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import java.util.List; + @RestController @RequestMapping("/api/auth") public class AuthController { private final AuthService authService; private final AuthenticationManager authenticationManager; + private final OAuthProviderStatusService oauthProviderStatusService; - public AuthController(AuthService authService, AuthenticationManager authenticationManager) { + public AuthController( + AuthService authService, + AuthenticationManager authenticationManager, + OAuthProviderStatusService oauthProviderStatusService + ) { this.authService = authService; this.authenticationManager = authenticationManager; + this.oauthProviderStatusService = oauthProviderStatusService; } @PostMapping("/register") @@ -65,6 +74,11 @@ public CurrentUserResponse me(Authentication authentication) { return CurrentUserResponse.from(authService.markSeen(principal.getUserId())); } + @GetMapping("/oauth/providers") + public List oauthProviders() { + return oauthProviderStatusService.providers(); + } + @PostMapping("/logout") @ResponseStatus(HttpStatus.NO_CONTENT) public void logout(HttpServletRequest request, HttpServletResponse response) { diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthAccount.java b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthAccount.java new file mode 100644 index 0000000..6e03403 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthAccount.java @@ -0,0 +1,101 @@ +package com.youbuyfirst.backend.auth; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "user_oauth_accounts") +public class OAuthAccount { + + @Id + @Column(length = 36) + private String id; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private AppUser user; + + @Column(nullable = false, length = 40) + private String provider; + + @Column(name = "provider_user_id", nullable = false, length = 128) + private String providerUserId; + + @Column(length = 255) + private String email; + + @Column(name = "display_name", length = 100) + private String displayName; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "last_login_at", nullable = false) + private Instant lastLoginAt; + + protected OAuthAccount() { + } + + private OAuthAccount( + String id, + AppUser user, + String provider, + String providerUserId, + String email, + String displayName, + Instant createdAt, + Instant lastLoginAt + ) { + this.id = id; + this.user = user; + this.provider = provider; + this.providerUserId = providerUserId; + this.email = email; + this.displayName = displayName; + this.createdAt = createdAt; + this.lastLoginAt = lastLoginAt; + } + + public static OAuthAccount link(AppUser user, OAuthProviderProfile profile, Instant now) { + return new OAuthAccount( + UUID.randomUUID().toString(), + user, + profile.provider(), + profile.providerUserId(), + profile.email(), + profile.displayName(), + now, + now + ); + } + + public void markLogin(OAuthProviderProfile profile, Instant now) { + this.email = profile.email(); + this.displayName = profile.displayName(); + this.lastLoginAt = now; + } + + public AppUser getUser() { + return user; + } + + public String getProvider() { + return provider; + } + + public String getProviderUserId() { + return providerUserId; + } + + public Instant getLastLoginAt() { + return lastLoginAt; + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthAccountRepository.java b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthAccountRepository.java new file mode 100644 index 0000000..56efa5d --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthAccountRepository.java @@ -0,0 +1,9 @@ +package com.youbuyfirst.backend.auth; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OAuthAccountRepository extends JpaRepository { + Optional findByProviderAndProviderUserId(String provider, String providerUserId); +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthAccountService.java b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthAccountService.java new file mode 100644 index 0000000..c81a797 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthAccountService.java @@ -0,0 +1,154 @@ +package com.youbuyfirst.backend.auth; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Locale; +import java.util.UUID; + +@Service +public class OAuthAccountService { + + private static final int USERNAME_MAX_LENGTH = 20; + private static final int DISPLAY_NAME_MAX_LENGTH = 20; + + private final AppUserRepository appUserRepository; + private final OAuthAccountRepository oauthAccountRepository; + private final PasswordEncoder passwordEncoder; + + public OAuthAccountService( + AppUserRepository appUserRepository, + OAuthAccountRepository oauthAccountRepository, + PasswordEncoder passwordEncoder + ) { + this.appUserRepository = appUserRepository; + this.oauthAccountRepository = oauthAccountRepository; + this.passwordEncoder = passwordEncoder; + } + + @Transactional + public AppUser findOrCreateUser(OAuthProviderProfile profile) { + Instant now = Instant.now(); + return oauthAccountRepository.findByProviderAndProviderUserId(profile.provider(), profile.providerUserId()) + .map(account -> { + account.markLogin(profile, now); + account.getUser().markSeen(now); + return account.getUser(); + }) + .orElseGet(() -> linkNewProviderIdentity(profile, now)); + } + + private AppUser linkNewProviderIdentity(OAuthProviderProfile profile, Instant now) { + AppUser user = findExistingUserByEmail(profile) + .orElseGet(() -> createOAuthUser(profile, now)); + user.markSeen(now); + oauthAccountRepository.save(OAuthAccount.link(user, profile, now)); + return user; + } + + private java.util.Optional findExistingUserByEmail(OAuthProviderProfile profile) { + if (profile.email().isBlank()) { + return java.util.Optional.empty(); + } + return appUserRepository.findByEmail(AuthSupport.normalizeEmail(profile.email())); + } + + private AppUser createOAuthUser(OAuthProviderProfile profile, Instant now) { + String username = uniqueUsername(profile); + String email = uniqueEmail(profile); + String displayName = uniqueDisplayName(profile); + String passwordHash = passwordEncoder.encode("oauth-" + UUID.randomUUID()); + return appUserRepository.save(AppUser.oauth( + username, + email, + displayName, + passwordHash, + profile.provider(), + now + )); + } + + private String uniqueUsername(OAuthProviderProfile profile) { + String localPart = profile.email().contains("@") ? profile.email().split("@", 2)[0] : ""; + String base = alphanumeric(localPart); + if (base.length() < 4) { + base = alphanumeric(profile.provider() + hash(profile.providerUserId())); + } + if (base.length() < 4) { + base = "user" + shortHash(profile.provider() + profile.providerUserId()); + } + base = trim(base.toLowerCase(Locale.ROOT), USERNAME_MAX_LENGTH); + + String candidate = base; + int attempt = 0; + while (appUserRepository.existsByUsername(candidate)) { + String suffix = shortHash(profile.provider() + profile.providerUserId() + attempt); + candidate = trim(base, USERNAME_MAX_LENGTH - suffix.length()) + suffix; + attempt++; + } + return candidate; + } + + private String uniqueEmail(OAuthProviderProfile profile) { + if (!profile.email().isBlank() && appUserRepository.findByEmail(profile.email()).isEmpty()) { + return profile.email(); + } + + String local = trim(profile.provider() + "-" + shortHash(profile.providerUserId()), 64); + String candidate = local + "@oauth.local"; + int attempt = 0; + while (appUserRepository.existsByEmail(candidate)) { + String suffix = shortHash(profile.providerUserId() + attempt); + candidate = trim(local, 64 - suffix.length()) + suffix + "@oauth.local"; + attempt++; + } + return candidate; + } + + private String uniqueDisplayName(OAuthProviderProfile profile) { + String base = profile.displayName().isBlank() + ? profile.provider() + " user" + : profile.displayName(); + base = trim(base, DISPLAY_NAME_MAX_LENGTH); + + String candidate = base; + int attempt = 0; + while (appUserRepository.existsByDisplayName(candidate)) { + String suffix = "-" + shortHash(profile.provider() + profile.providerUserId() + attempt).substring(0, 4); + candidate = trim(base, DISPLAY_NAME_MAX_LENGTH - suffix.length()) + suffix; + attempt++; + } + return candidate; + } + + private static String alphanumeric(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", ""); + } + + private static String trim(String value, int maxLength) { + String trimmed = value == null ? "" : value.trim(); + if (trimmed.length() <= maxLength) { + return trimmed; + } + return trimmed.substring(0, Math.max(0, maxLength)); + } + + private static String shortHash(String value) { + return hash(value).substring(0, 8); + } + + private static String hash(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is required", ex); + } + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthClientRegistrationConfig.java b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthClientRegistrationConfig.java new file mode 100644 index 0000000..6f62cb2 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthClientRegistrationConfig.java @@ -0,0 +1,152 @@ +package com.youbuyfirst.backend.auth; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.security.config.oauth2.client.CommonOAuth2Provider; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Configuration +public class OAuthClientRegistrationConfig { + + private static final String DEFAULT_REDIRECT_URI = "{baseUrl}/login/oauth2/code/{registrationId}"; + + @Bean + @Conditional(OAuthClientsConfiguredCondition.class) + ClientRegistrationRepository clientRegistrationRepository(Environment environment) { + List registrations = new ArrayList<>(); + google(environment).ifPresent(registrations::add); + naver(environment).ifPresent(registrations::add); + kakao(environment).ifPresent(registrations::add); + return new InMemoryClientRegistrationRepository(registrations); + } + + private static Optional google(Environment environment) { + Credentials credentials = credentials(environment, "google"); + if (!credentials.hasClientIdAndSecret()) { + return Optional.empty(); + } + + return Optional.of(CommonOAuth2Provider.GOOGLE.getBuilder("google") + .clientId(credentials.clientId()) + .clientSecret(credentials.clientSecret()) + .redirectUri(redirectUri(environment)) + .scope("openid", "profile", "email") + .build()); + } + + private static Optional naver(Environment environment) { + Credentials credentials = credentials(environment, "naver"); + if (!credentials.hasClientIdAndSecret()) { + return Optional.empty(); + } + + return Optional.of(ClientRegistration.withRegistrationId("naver") + .clientId(credentials.clientId()) + .clientSecret(credentials.clientSecret()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(redirectUri(environment)) + .authorizationUri("https://nid.naver.com/oauth2.0/authorize") + .tokenUri("https://nid.naver.com/oauth2.0/token") + .userInfoUri("https://openapi.naver.com/v1/nid/me") + .userNameAttributeName("response") + .clientName("Naver") + .build()); + } + + private static Optional kakao(Environment environment) { + Credentials credentials = credentials(environment, "kakao"); + if (!StringUtils.hasText(credentials.clientId())) { + return Optional.empty(); + } + + ClientAuthenticationMethod authenticationMethod = StringUtils.hasText(credentials.clientSecret()) + ? ClientAuthenticationMethod.CLIENT_SECRET_POST + : ClientAuthenticationMethod.NONE; + + ClientRegistration.Builder builder = ClientRegistration.withRegistrationId("kakao") + .clientId(credentials.clientId()) + .clientAuthenticationMethod(authenticationMethod) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(redirectUri(environment)) + .authorizationUri("https://kauth.kakao.com/oauth/authorize") + .tokenUri("https://kauth.kakao.com/oauth/token") + .userInfoUri("https://kapi.kakao.com/v2/user/me") + .userNameAttributeName("id") + .clientName("Kakao"); + + if (StringUtils.hasText(credentials.clientSecret())) { + builder.clientSecret(credentials.clientSecret()); + } + + return Optional.of(builder.build()); + } + + private static String redirectUri(Environment environment) { + String redirectBaseUrl = globalProperty(environment, "redirect-base-url"); + if (!StringUtils.hasText(redirectBaseUrl)) { + return DEFAULT_REDIRECT_URI; + } + return redirectBaseUrl.replaceAll("/+$", "") + "/login/oauth2/code/{registrationId}"; + } + + private static Credentials credentials(Environment environment, String provider) { + return new Credentials( + property(environment, provider, "client-id"), + property(environment, provider, "client-secret") + ); + } + + private static String property(Environment environment, String provider, String name) { + String configured = environment.getProperty("app.auth.oauth." + provider + "." + name); + if (StringUtils.hasText(configured)) { + return configured.trim(); + } + + String envName = "OAUTH_" + provider.toUpperCase(java.util.Locale.ROOT) + "_" + + name.toUpperCase(java.util.Locale.ROOT).replace("-", "_"); + String fromEnv = environment.getProperty(envName); + return StringUtils.hasText(fromEnv) ? fromEnv.trim() : ""; + } + + private static String globalProperty(Environment environment, String name) { + String configured = environment.getProperty("app.auth.oauth." + name); + if (StringUtils.hasText(configured)) { + return configured.trim(); + } + + String envName = "APP_AUTH_OAUTH_" + name.toUpperCase(java.util.Locale.ROOT).replace("-", "_"); + String fromEnv = environment.getProperty(envName); + return StringUtils.hasText(fromEnv) ? fromEnv.trim() : ""; + } + + private record Credentials(String clientId, String clientSecret) { + boolean hasClientIdAndSecret() { + return StringUtils.hasText(clientId) && StringUtils.hasText(clientSecret); + } + } + + static class OAuthClientsConfiguredCondition implements Condition { + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + Environment environment = context.getEnvironment(); + return credentials(environment, "google").hasClientIdAndSecret() + || credentials(environment, "naver").hasClientIdAndSecret() + || StringUtils.hasText(credentials(environment, "kakao").clientId()); + } + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthLoginHandlers.java b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthLoginHandlers.java new file mode 100644 index 0000000..584e1eb --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthLoginHandlers.java @@ -0,0 +1,29 @@ +package com.youbuyfirst.backend.auth; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; + +@Configuration +public class OAuthLoginHandlers { + + @Bean + AuthenticationSuccessHandler oauthAuthenticationSuccessHandler( + @Value("${app.auth.oauth.success-redirect:/dashboard}") String successRedirect + ) { + SimpleUrlAuthenticationSuccessHandler handler = new SimpleUrlAuthenticationSuccessHandler(successRedirect); + handler.setAlwaysUseDefaultTargetUrl(true); + return handler; + } + + @Bean + AuthenticationFailureHandler oauthAuthenticationFailureHandler( + @Value("${app.auth.oauth.failure-redirect:/auth/login?oauth=failed}") String failureRedirect + ) { + return new SimpleUrlAuthenticationFailureHandler(failureRedirect); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthProviderProfile.java b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthProviderProfile.java new file mode 100644 index 0000000..7a61639 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthProviderProfile.java @@ -0,0 +1,87 @@ +package com.youbuyfirst.backend.auth; + +import java.util.Locale; +import java.util.Map; + +public record OAuthProviderProfile( + String provider, + String providerUserId, + String email, + String displayName +) { + public OAuthProviderProfile { + provider = text(provider).toLowerCase(Locale.ROOT); + providerUserId = text(providerUserId); + email = AuthSupport.normalizeEmail(email); + displayName = AuthSupport.normalizeDisplayName(displayName); + if (provider.isBlank()) { + throw new IllegalArgumentException("OAuth provider is required"); + } + if (providerUserId.isBlank()) { + throw new IllegalArgumentException("OAuth provider user id is required"); + } + } + + public static OAuthProviderProfile from(String registrationId, Map attributes) { + String provider = text(registrationId).toLowerCase(Locale.ROOT); + return switch (provider) { + case "google" -> fromGoogle(attributes); + case "naver" -> fromNaver(attributes); + case "kakao" -> fromKakao(attributes); + default -> throw new IllegalArgumentException("Unsupported OAuth provider: " + registrationId); + }; + } + + private static OAuthProviderProfile fromGoogle(Map attributes) { + return new OAuthProviderProfile( + "google", + text(attributes.get("sub")), + text(attributes.get("email")), + text(attributes.get("name")) + ); + } + + private static OAuthProviderProfile fromNaver(Map attributes) { + Map response = nestedMap(attributes.get("response")); + return new OAuthProviderProfile( + "naver", + text(response.get("id")), + text(response.get("email")), + firstText(response.get("nickname"), response.get("name")) + ); + } + + private static OAuthProviderProfile fromKakao(Map attributes) { + Map account = nestedMap(attributes.get("kakao_account")); + Map profile = nestedMap(account.get("profile")); + return new OAuthProviderProfile( + "kakao", + text(attributes.get("id")), + text(account.get("email")), + firstText(profile.get("nickname"), attributes.get("properties.nickname")) + ); + } + + private static Map nestedMap(Object value) { + if (value instanceof Map map) { + return map.entrySet().stream() + .filter(entry -> entry.getKey() instanceof String) + .collect( + java.util.stream.Collectors.toMap( + entry -> (String) entry.getKey(), + Map.Entry::getValue + ) + ); + } + return Map.of(); + } + + private static String firstText(Object first, Object second) { + String value = text(first); + return value.isBlank() ? text(second) : value; + } + + private static String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthProviderStatusService.java b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthProviderStatusService.java new file mode 100644 index 0000000..0eba4d9 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/OAuthProviderStatusService.java @@ -0,0 +1,47 @@ +package com.youbuyfirst.backend.auth; + +import com.youbuyfirst.backend.auth.dto.OAuthProviderStatusResponse; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class OAuthProviderStatusService { + + private static final List PROVIDERS = List.of( + new ProviderDefinition("google", "Google"), + new ProviderDefinition("naver", "Naver"), + new ProviderDefinition("kakao", "Kakao") + ); + + private final ObjectProvider clientRegistrationRepository; + + public OAuthProviderStatusService(ObjectProvider clientRegistrationRepository) { + this.clientRegistrationRepository = clientRegistrationRepository; + } + + public List providers() { + ClientRegistrationRepository registrations = clientRegistrationRepository.getIfAvailable(); + return PROVIDERS.stream() + .map(provider -> new OAuthProviderStatusResponse( + provider.id(), + provider.displayName(), + authorizationUrl(provider.id()), + isConfigured(registrations, provider.id()) + )) + .toList(); + } + + private static boolean isConfigured(ClientRegistrationRepository registrations, String provider) { + return registrations != null && registrations.findByRegistrationId(provider) != null; + } + + private static String authorizationUrl(String provider) { + return "/oauth2/authorization/" + provider; + } + + private record ProviderDefinition(String id, String displayName) { + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/SecurityConfig.java b/backend/src/main/java/com/youbuyfirst/backend/auth/SecurityConfig.java index d2da44d..38d2557 100644 --- a/backend/src/main/java/com/youbuyfirst/backend/auth/SecurityConfig.java +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/SecurityConfig.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; @@ -13,13 +14,28 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @Configuration public class SecurityConfig { @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + ObjectProvider clientRegistrationRepository, + OAuth2UserService oauth2UserService, + OAuth2UserService oidcUserService, + AuthenticationSuccessHandler oauthAuthenticationSuccessHandler, + AuthenticationFailureHandler oauthAuthenticationFailureHandler + ) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) @@ -32,9 +48,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(authorize -> authorize .requestMatchers(HttpMethod.GET, "/api/auth/me").authenticated() .requestMatchers(HttpMethod.POST, "/api/auth/logout").authenticated() + .requestMatchers("/api/realestate/watch-targets", "/api/realestate/watch-targets/**").authenticated() .anyRequest().permitAll() ); + if (clientRegistrationRepository.getIfAvailable() != null) { + http.oauth2Login(oauth -> oauth + .userInfoEndpoint(userInfo -> userInfo + .userService(oauth2UserService) + .oidcUserService(oidcUserService) + ) + .successHandler(oauthAuthenticationSuccessHandler) + .failureHandler(oauthAuthenticationFailureHandler) + ); + } + return http.build(); } diff --git a/backend/src/main/java/com/youbuyfirst/backend/auth/dto/OAuthProviderStatusResponse.java b/backend/src/main/java/com/youbuyfirst/backend/auth/dto/OAuthProviderStatusResponse.java new file mode 100644 index 0000000..bd1f615 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/auth/dto/OAuthProviderStatusResponse.java @@ -0,0 +1,9 @@ +package com.youbuyfirst.backend.auth.dto; + +public record OAuthProviderStatusResponse( + String provider, + String displayName, + String authorizationUrl, + boolean configured +) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessage.java b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessage.java new file mode 100644 index 0000000..ee1da04 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessage.java @@ -0,0 +1,89 @@ +package com.youbuyfirst.backend.chat; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; + +@Entity +@Table(name = "chat_messages") +public class ChatMessage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", length = 36) + private String userId; + + @Column(name = "session_id", length = 96) + private String sessionId; + + @Column(name = "author_display_name", nullable = false, length = 20) + private String authorDisplayName; + + @Column(nullable = false, length = 500) + private String body; + + @Column(name = "attachment_json", columnDefinition = "TEXT") + private String attachmentJson; + + @Column(nullable = false, length = 20) + private String category; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected ChatMessage() { + } + + private ChatMessage(String userId, String sessionId, String authorDisplayName, String body, String attachmentJson, Instant createdAt) { + this.userId = userId; + this.sessionId = sessionId; + this.authorDisplayName = authorDisplayName; + this.body = body; + this.attachmentJson = attachmentJson; + this.category = "chat"; + this.createdAt = createdAt; + } + + public static ChatMessage create(String userId, String sessionId, String authorDisplayName, String body, String attachmentJson, Instant createdAt) { + return new ChatMessage(userId, sessionId, authorDisplayName, body, attachmentJson, createdAt); + } + + public Long getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public String getSessionId() { + return sessionId; + } + + public String getAuthorDisplayName() { + return authorDisplayName; + } + + public String getBody() { + return body; + } + + public String getAttachmentJson() { + return attachmentJson; + } + + public String getCategory() { + return category; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessageController.java b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessageController.java new file mode 100644 index 0000000..c453f3e --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessageController.java @@ -0,0 +1,68 @@ +package com.youbuyfirst.backend.chat; + +import com.youbuyfirst.backend.auth.AppUserPrincipal; +import com.youbuyfirst.backend.chat.dto.ChatMessageRequest; +import com.youbuyfirst.backend.chat.dto.ChatMessageResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +@RestController +@RequestMapping("/api/chat/messages") +public class ChatMessageController { + + private final ChatMessageService service; + private final ChatMessageStream stream; + + public ChatMessageController(ChatMessageService service, ChatMessageStream stream) { + this.service = service; + this.stream = stream; + } + + @GetMapping + public List recent( + @RequestParam(defaultValue = "100") int limit, + Authentication authentication, + HttpServletRequest request + ) { + return service.recent(limit, currentUserId(authentication), currentSessionId(request, false)); + } + + @GetMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter stream() { + return stream.open(); + } + + @PostMapping + public ResponseEntity create( + @AuthenticationPrincipal AppUserPrincipal principal, + HttpServletRequest servletRequest, + @Valid @RequestBody ChatMessageRequest request + ) { + return ResponseEntity.status(201).body(service.create(principal, currentSessionId(servletRequest, true), request)); + } + + private static String currentUserId(Authentication authentication) { + if (authentication == null || !(authentication.getPrincipal() instanceof AppUserPrincipal principal)) { + return null; + } + return principal.getUserId(); + } + + private static String currentSessionId(HttpServletRequest request, boolean create) { + var session = request.getSession(create); + return session == null ? null : session.getId(); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessageRepository.java b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessageRepository.java new file mode 100644 index 0000000..9741fd6 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessageRepository.java @@ -0,0 +1,6 @@ +package com.youbuyfirst.backend.chat; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatMessageRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessageService.java b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessageService.java new file mode 100644 index 0000000..f6f832c --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessageService.java @@ -0,0 +1,227 @@ +package com.youbuyfirst.backend.chat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.youbuyfirst.backend.auth.AppUserRepository; +import com.youbuyfirst.backend.auth.AppUserPrincipal; +import com.youbuyfirst.backend.chat.dto.ChatMessageAttachment; +import com.youbuyfirst.backend.chat.dto.ChatMessageRequest; +import com.youbuyfirst.backend.chat.dto.ChatMessageResponse; +import com.youbuyfirst.backend.chat.dto.ChatNicknameAvailabilityResponse; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Service +public class ChatMessageService { + + private static final int MAX_LIMIT = 100; + private static final int DEFAULT_LIMIT = MAX_LIMIT; + + private final ChatMessageRepository repository; + private final ChatMessageStream stream; + private final ObjectMapper objectMapper; + private final AppUserRepository appUserRepository; + + public ChatMessageService( + ChatMessageRepository repository, + ChatMessageStream stream, + ObjectMapper objectMapper, + AppUserRepository appUserRepository + ) { + this.repository = repository; + this.stream = stream; + this.objectMapper = objectMapper; + this.appUserRepository = appUserRepository; + } + + @Transactional + public ChatMessageResponse create(AppUserPrincipal principal, String currentSessionId, ChatMessageRequest request) { + String body = normalizeBody(request.body()); + String currentUserId = principal == null ? null : principal.getUserId(); + String fallbackDisplayName = principal == null ? "손님" : principal.getDisplayName(); + String displayName = normalizeDisplayName(request.displayName(), fallbackDisplayName); + if (principal == null && appUserRepository.existsByDisplayName(displayName)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Chat display name is already registered"); + } + ChatMessageAttachment attachment = normalizeAttachment(request.attachment()); + Instant now = Instant.now(); + + ChatMessage message = repository.save(ChatMessage.create( + currentUserId, + currentSessionId, + displayName, + body, + serializeAttachment(attachment), + now + )); + + ChatMessageResponse response = ChatMessageResponse.from(message, currentUserId, currentSessionId, now, attachment); + broadcastAfterCommit(response); + return response; + } + + @Transactional(readOnly = true) + public List recent(int limit, String currentUserId, String currentSessionId) { + Instant now = Instant.now(); + List newestFirst = repository.findAll(PageRequest.of( + 0, + normalizedLimit(limit), + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id")) + )).getContent(); + + List chronological = new ArrayList<>(newestFirst); + java.util.Collections.reverse(chronological); + + return chronological.stream() + .map(message -> ChatMessageResponse.from( + message, + currentUserId, + currentSessionId, + now, + deserializeAttachment(message.getAttachmentJson()) + )) + .toList(); + } + + @Transactional(readOnly = true) + public ChatNicknameAvailabilityResponse nicknameAvailability(String displayName) { + String normalized = normalizeGuestDisplayNameCandidate(displayName); + return new ChatNicknameAvailabilityResponse(!appUserRepository.existsByDisplayName(normalized)); + } + + private static int normalizedLimit(int limit) { + if (limit <= 0) { + return DEFAULT_LIMIT; + } + return Math.min(limit, MAX_LIMIT); + } + + private static String normalizeBody(String value) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Chat message body is required"); + } + if (normalized.length() > 500) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Chat message body is too long"); + } + return normalized; + } + + private static String normalizeDisplayName(String requestedDisplayName, String fallbackDisplayName) { + String normalized = requestedDisplayName == null ? "" : requestedDisplayName.trim(); + if (normalized.isEmpty()) { + normalized = fallbackDisplayName; + } + if (normalized.length() > 20) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Chat display name is too long"); + } + return normalized; + } + + private static String normalizeGuestDisplayNameCandidate(String value) { + String normalized = trimOptional(value); + if (normalized == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Chat display name is required"); + } + if (normalized.length() > 20) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Chat display name is too long"); + } + return normalized; + } + + private ChatMessageAttachment normalizeAttachment(ChatMessageAttachment attachment) { + if (attachment == null) { + return null; + } + + String type = trimRequired(attachment.type(), "Chat attachment type is required"); + if (!type.equals("region") && !type.equals("complex")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Chat attachment type is not supported"); + } + + String targetId = trimRequired(attachment.targetId(), "Chat attachment target is required"); + String title = trimRequired(attachment.title(), "Chat attachment title is required"); + String landingPath = trimRequired(attachment.landingPath(), "Chat attachment landing path is required"); + if (!landingPath.startsWith("/realestate/") || landingPath.startsWith("//") || hasLineBreak(landingPath)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Chat attachment landing path is not supported"); + } + + String metricTone = trimOptional(attachment.metricTone()); + if (metricTone != null && !metricTone.equals("up") && !metricTone.equals("down") && !metricTone.equals("flat")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Chat attachment metric tone is not supported"); + } + + return new ChatMessageAttachment( + type, + targetId, + title, + trimOptional(attachment.subtitle()), + trimOptional(attachment.metricLabel()), + trimOptional(attachment.metricValue()), + metricTone, + landingPath + ); + } + + private String serializeAttachment(ChatMessageAttachment attachment) { + if (attachment == null) { + return null; + } + try { + return objectMapper.writeValueAsString(attachment); + } catch (JsonProcessingException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Chat attachment could not be stored"); + } + } + + private ChatMessageAttachment deserializeAttachment(String attachmentJson) { + if (attachmentJson == null || attachmentJson.isBlank()) { + return null; + } + try { + return objectMapper.readValue(attachmentJson, ChatMessageAttachment.class); + } catch (JsonProcessingException ex) { + return null; + } + } + + private static String trimRequired(String value, String message) { + String normalized = trimOptional(value); + if (normalized == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + return normalized; + } + + private static String trimOptional(String value) { + String normalized = value == null ? "" : value.trim(); + return normalized.isEmpty() ? null : normalized; + } + + private static boolean hasLineBreak(String value) { + return value.contains("\n") || value.contains("\r"); + } + + private void broadcastAfterCommit(ChatMessageResponse response) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + stream.broadcast(response); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + stream.broadcast(response); + } + }); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessageStream.java b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessageStream.java new file mode 100644 index 0000000..4a3c4ed --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatMessageStream.java @@ -0,0 +1,64 @@ +package com.youbuyfirst.backend.chat; + +import com.youbuyfirst.backend.chat.dto.ChatMessageResponse; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +@Service +public class ChatMessageStream { + + private final List emitters = new CopyOnWriteArrayList<>(); + + public SseEmitter open() { + SseEmitter emitter = new SseEmitter(0L); + emitters.add(emitter); + emitter.onCompletion(() -> emitters.remove(emitter)); + emitter.onTimeout(() -> emitters.remove(emitter)); + emitter.onError(error -> emitters.remove(emitter)); + + try { + emitter.send(SseEmitter.event().name("ready").data("ok")); + } catch (IOException | IllegalStateException ex) { + removeEmitter(emitter, ex); + } + + return emitter; + } + + public void broadcast(ChatMessageResponse response) { + ChatMessageResponse broadcastResponse = new ChatMessageResponse( + response.id(), + response.author(), + response.badge(), + response.body(), + response.timeLabel(), + response.category(), + false, + response.tone(), + response.verified(), + response.createdAt(), + response.attachment() + ); + + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event().name("chat-message").data(broadcastResponse)); + } catch (IOException | IllegalStateException ex) { + removeEmitter(emitter, ex); + } + } + } + + private void removeEmitter(SseEmitter emitter, Exception cause) { + emitters.remove(emitter); + try { + emitter.completeWithError(cause); + } catch (RuntimeException ignored) { + // The client may already be gone; removing the emitter is enough. + } + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/ChatNicknameController.java b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatNicknameController.java new file mode 100644 index 0000000..1e8548c --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatNicknameController.java @@ -0,0 +1,23 @@ +package com.youbuyfirst.backend.chat; + +import com.youbuyfirst.backend.chat.dto.ChatNicknameAvailabilityResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/chat") +public class ChatNicknameController { + + private final ChatMessageService service; + + public ChatNicknameController(ChatMessageService service) { + this.service = service; + } + + @GetMapping("/nickname-availability") + public ChatNicknameAvailabilityResponse nicknameAvailability(@RequestParam String displayName) { + return service.nicknameAvailability(displayName); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/ChatPresenceConfiguration.java b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatPresenceConfiguration.java new file mode 100644 index 0000000..d3b2b6b --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatPresenceConfiguration.java @@ -0,0 +1,16 @@ +package com.youbuyfirst.backend.chat; + +import org.springframework.boot.web.servlet.ServletListenerRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ChatPresenceConfiguration { + + @Bean + public ServletListenerRegistrationBean chatPresenceSessionListener( + ChatPresenceCounter counter + ) { + return new ServletListenerRegistrationBean<>(counter); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/ChatPresenceController.java b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatPresenceController.java new file mode 100644 index 0000000..6276eaf --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatPresenceController.java @@ -0,0 +1,24 @@ +package com.youbuyfirst.backend.chat; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/chat") +public class ChatPresenceController { + + private final ChatPresenceCounter counter; + + public ChatPresenceController(ChatPresenceCounter counter) { + this.counter = counter; + } + + @GetMapping("/presence") + public ChatPresenceResponse presence(HttpServletRequest request) { + HttpSession session = request.getSession(true); + return new ChatPresenceResponse(counter.markPresent(session.getId()), "chat_presence_heartbeat"); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/ChatPresenceCounter.java b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatPresenceCounter.java new file mode 100644 index 0000000..4402a1a --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatPresenceCounter.java @@ -0,0 +1,54 @@ +package com.youbuyfirst.backend.chat; + +import jakarta.servlet.http.HttpSessionEvent; +import jakarta.servlet.http.HttpSessionListener; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +@Component +public class ChatPresenceCounter implements HttpSessionListener { + + private static final Duration DEFAULT_HEARTBEAT_TTL = Duration.ofSeconds(45); + + private final Clock clock; + private final Duration heartbeatTtl; + private final ConcurrentMap chatSessionLastSeen = new ConcurrentHashMap<>(); + + public ChatPresenceCounter() { + this(Clock.systemUTC(), DEFAULT_HEARTBEAT_TTL); + } + + ChatPresenceCounter(Clock clock, Duration heartbeatTtl) { + this.clock = clock; + this.heartbeatTtl = heartbeatTtl; + } + + @Override + public void sessionCreated(HttpSessionEvent event) { + } + + @Override + public void sessionDestroyed(HttpSessionEvent event) { + chatSessionLastSeen.remove(event.getSession().getId()); + } + + public int markPresent(String sessionId) { + chatSessionLastSeen.put(sessionId, Instant.now(clock)); + return activeChatSessionCount(); + } + + public int activeChatSessionCount() { + purgeExpiredSessions(); + return chatSessionLastSeen.size(); + } + + private void purgeExpiredSessions() { + Instant cutoff = Instant.now(clock).minus(heartbeatTtl); + chatSessionLastSeen.entrySet().removeIf(entry -> entry.getValue().isBefore(cutoff)); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/ChatPresenceResponse.java b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatPresenceResponse.java new file mode 100644 index 0000000..706424f --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/ChatPresenceResponse.java @@ -0,0 +1,7 @@ +package com.youbuyfirst.backend.chat; + +public record ChatPresenceResponse( + int activeSessionCount, + String source +) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/dto/ChatMessageAttachment.java b/backend/src/main/java/com/youbuyfirst/backend/chat/dto/ChatMessageAttachment.java new file mode 100644 index 0000000..19a7dc1 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/dto/ChatMessageAttachment.java @@ -0,0 +1,16 @@ +package com.youbuyfirst.backend.chat.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ChatMessageAttachment( + @NotBlank @Size(max = 20) String type, + @NotBlank @Size(max = 80) String targetId, + @NotBlank @Size(max = 80) String title, + @Size(max = 120) String subtitle, + @Size(max = 40) String metricLabel, + @Size(max = 40) String metricValue, + @Size(max = 10) String metricTone, + @NotBlank @Size(max = 300) String landingPath +) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/dto/ChatMessageRequest.java b/backend/src/main/java/com/youbuyfirst/backend/chat/dto/ChatMessageRequest.java new file mode 100644 index 0000000..fb44b86 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/dto/ChatMessageRequest.java @@ -0,0 +1,12 @@ +package com.youbuyfirst.backend.chat.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ChatMessageRequest( + @NotBlank @Size(max = 500) String body, + @Size(max = 20) String displayName, + @Valid ChatMessageAttachment attachment +) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/dto/ChatMessageResponse.java b/backend/src/main/java/com/youbuyfirst/backend/chat/dto/ChatMessageResponse.java new file mode 100644 index 0000000..9751f99 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/dto/ChatMessageResponse.java @@ -0,0 +1,66 @@ +package com.youbuyfirst.backend.chat.dto; + +import com.youbuyfirst.backend.chat.ChatMessage; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +public record ChatMessageResponse( + String id, + String author, + String badge, + String body, + String timeLabel, + String category, + boolean mine, + String tone, + boolean verified, + Instant createdAt, + ChatMessageAttachment attachment +) { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter OLD_MESSAGE_FORMAT = DateTimeFormatter.ofPattern("MM.dd").withZone(KST); + + public static ChatMessageResponse from( + ChatMessage message, + String currentUserId, + String currentSessionId, + Instant now, + ChatMessageAttachment attachment + ) { + boolean verified = message.getUserId() != null; + boolean mine = verified + ? message.getUserId().equals(currentUserId) + : message.getSessionId() != null && message.getSessionId().equals(currentSessionId); + return new ChatMessageResponse( + "server-chat-" + message.getId(), + message.getAuthorDisplayName(), + "채팅", + message.getBody(), + timeLabel(message.getCreatedAt(), now), + message.getCategory(), + mine, + "blue", + verified, + message.getCreatedAt(), + attachment + ); + } + + private static String timeLabel(Instant createdAt, Instant now) { + Duration age = Duration.between(createdAt, now); + if (age.isNegative() || age.toMinutes() < 1) { + return "방금"; + } + if (age.toHours() < 1) { + return age.toMinutes() + "분 전"; + } + if (age.toHours() < 24) { + return age.toHours() + "시간 전"; + } + return OLD_MESSAGE_FORMAT.format(createdAt); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/chat/dto/ChatNicknameAvailabilityResponse.java b/backend/src/main/java/com/youbuyfirst/backend/chat/dto/ChatNicknameAvailabilityResponse.java new file mode 100644 index 0000000..273a50a --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/chat/dto/ChatNicknameAvailabilityResponse.java @@ -0,0 +1,4 @@ +package com.youbuyfirst.backend.chat.dto; + +public record ChatNicknameAvailabilityResponse(boolean available) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/MarketDataScheduleService.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/MarketDataScheduleService.java index e69b641..ae05eb7 100644 --- a/backend/src/main/java/com/youbuyfirst/backend/realestate/MarketDataScheduleService.java +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/MarketDataScheduleService.java @@ -34,15 +34,36 @@ public class MarketDataScheduleService { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final String REB_BULLETIN_SEARCH_URL = "https://www.reb.or.kr/r-one/portal/bbs/pres/searchBulletin.do"; private static final String REB_BULLETIN_DETAIL_URL = "https://www.reb.or.kr/r-one/portal/bbs/pres/selectBulletinPage.do"; + private static final String REB_STAT_SCHEDULE_URL = "https://www.reb.or.kr/r-one/portal/compose/scheduleStatsPage.do"; + private static final String REB_STAT_DETAIL_URL = "https://www.reb.or.kr/r-one/portal/stat/easyStatPage.do"; private static final String MOLIT_POLICY_LIST_URL = "https://www.molit.go.kr/USR/NEWS/m_71/lst.jsp"; private static final String MOLIT_STAT_SCHEDULE_URL = "https://stat.molit.go.kr/portal/notice/scheduleList.do"; private static final String APPLYHOME_APT_LIST_URL = "https://www.applyhome.co.kr/ai/aia/selectAPTLttotPblancListView.do"; private static final String APPLYHOME_APT_DETAIL_URL = "https://www.applyhome.co.kr/ai/aia/selectAPTLttotPblancDetail.do"; private static final String BOK_RATE_SCHEDULE_URL = "https://www.bok.or.kr/portal/singl/crncyPolicyDrcMtg/listYear.do?menuNo=200755&mtgSe=A"; + private static final String BOK_STAT_SCHEDULE_URL = "https://www.bok.or.kr/portal/stats/statsPublictSchdul/listCldr.do?menuNo=200775"; + private static final String HUG_PRIVATE_APT_STAT_URL = "https://khug.or.kr/houstar/web/p01/03/p010301.jsp?currentPage=1"; + private static final String LH_PUBLIC_SALE_LIST_URL = "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1027"; + private static final String LH_PUBLIC_RENT_LIST_URL = "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1026"; + private static final String LH_PUBLIC_DETAIL_URL = "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do"; + private static final String SH_PUBLIC_LEASE_LIST_URL = "https://housing.seoul.go.kr/site/main/sh/publicLease/07/list"; + private static final String GH_PUBLIC_SUPPLY_LIST_URL = "https://www.gh.or.kr/gh/announcement-of-salerental001.do"; + private static final String IH_RENT_NOTICE_LIST_URL = "https://www.ih.co.kr/main/sale_lease/notice.jsp"; + private static final String IH_HOUSE_NOTICE_LIST_URL = "https://www.ih.co.kr/main/sale_lease/board/house_notice.jsp"; + private static final String FSC_PRESS_LIST_URL = "https://www.fsc.go.kr/no010101"; + private static final String REALTY_PRICE_BOARD_URL = "https://www.realtyprice.kr/notice/board/boardListAll.board"; + private static final String REALTY_PRICE_DETAIL_URL = "https://www.realtyprice.kr/notice/board/boardDetailAll.board"; private static final Pattern TABLE_ROW_PATTERN = Pattern.compile("(?is)]*)>(.*?)"); + private static final Pattern LIST_ITEM_PATTERN = Pattern.compile("(?is)]*>(.*?)"); + private static final Pattern TABLE_CELL_PATTERN = Pattern.compile("(?is)]*>(.*?)"); + private static final Pattern TABLE_CELL_WITH_ID_PATTERN = Pattern.compile("(?is)]*)\\bid=\"(\\d{8})\"[^>]*>(.*?)"); private static final Pattern MOLIT_TITLE_LINK_PATTERN = Pattern.compile("(?is)]*class=\"[^\"]*\\bbd_title\\b[^\"]*\"[^>]*>.*?]*href=\"([^\"]+)\"[^>]*>(.*?)"); + private static final Pattern REB_SCHEDULE_LINK_PATTERN = Pattern.compile("(?is)]*\\bdata-relId=\"([^\"]+)\"[^>]*>.*?]*>(.*?).*?

"); private static final Pattern LINK_PATTERN = Pattern.compile("(?is)]*href=\"([^\"]+)\"[^>]*>(.*?)"); + private static final Pattern REALTY_PRICE_DETAIL_PATTERN = Pattern.compile("(?is)goDetail\\(\\s*(\\d+)\\s*\\)"); private static final Pattern ISO_DATE_PATTERN = Pattern.compile("\\d{4}-\\d{2}-\\d{2}"); + private static final Pattern DOT_DATE_PATTERN = Pattern.compile("(? MOLIT_RELEVANT_FIELDS = Set.of("주택토지"); private static final List MOLIT_RELEVANT_TITLE_TERMS = List.of( @@ -74,22 +95,114 @@ public class MarketDataScheduleService { "부동산거래", "전월세" ); + private static final List HUG_RELEVANT_TITLE_TERMS = List.of( + "민간아파트", + "분양가격", + "분양가", + "초기분양률", + "분양시장", + "대지비", + "주택보증통계" + ); + private static final List PUBLIC_SUPPLY_RELEVANT_TITLE_TERMS = List.of( + "입주자모집", + "입주자 모집", + "공공주택", + "공공분양", + "분양 공고", + "분양주택", + "행복주택", + "국민임대", + "영구임대", + "장기전세", + "전세임대", + "매입임대", + "공공임대", + "주택 공급", + "주택분양", + "예비입주자" + ); + private static final List LH_MAJOR_SUPPLY_TITLE_TERMS = List.of( + "공공분양", + "공공분양주택", + "분양주택", + "공공임대주택리츠", + "공공임대리츠", + "토지임대부" + ); + private static final List LH_LOW_SIGNAL_TITLE_TERMS = List.of( + "정정공고", + "예비입주자", + "예비 입주자", + "행복주택", + "국민임대", + "영구임대", + "매입임대", + "전세임대", + "고령자", + "청년", + "기숙사형", + "자격완화", + "일반매각", + "선착순", + "잔여주택", + "동호지정" + ); + private static final List FINANCE_POLICY_RELEVANT_TITLE_TERMS = List.of( + "부동산", + "주담대", + "주택담보", + "가계부채", + "가계대출", + "전세대출", + "프로젝트금융", + " PF", + "LTV", + "DSR", + "대출규제" + ); + private static final List BOK_STAT_RELEVANT_TITLE_TERMS = List.of( + "자금순환", + "통화 및 유동성", + "국민대차대조표", + "생산자물가지수", + "국내총생산", + "금융기관 가중평균금리", + "소비자동향조사", + "기업경기조사", + "경제심리지수" + ); + private static final List OFFICIAL_PRICE_RELEVANT_TITLE_TERMS = List.of( + "공동주택가격", + "공시가격", + "결정·공시", + "열람", + "의견제출", + "이의신청" + ); private static final List REB_BULLETIN_CATEGORIES = List.of( new RebBulletinCategory("PRES01", "가격지수", "market"), new RebBulletinCategory("PRES02", "가격지수", "market"), new RebBulletinCategory("PRES05", "가격지수", "market"), new RebBulletinCategory("PRES06", "전세", "deal"), - new RebBulletinCategory("PRES07", "실거래", "deal") + new RebBulletinCategory("PRES07", "거래현황", "deal") ); private static final List SOURCES = List.of( - new SourceDefinition("reb-r-one", "한국부동산원 R-ONE", "가격지수·공표일정", "reb", "https://www.reb.or.kr/r-one/portal/main/indexPage.do", "가격지수", "market"), - new SourceDefinition("rt-molit", "국토교통부 실거래가 공개시스템", "매매·전월세 거래", "molit", "https://rt.molit.go.kr/", "실거래", "deal"), + new SourceDefinition("reb-r-one", "한국부동산원 R-ONE", "가격지수·거래현황 공표일정", "reb", REB_STAT_SCHEDULE_URL, "가격지수", "market"), + new SourceDefinition("rt-molit", "국토교통부 실거래가 공개시스템", "상시 공개·freshness 확인", "molit", "https://rt.molit.go.kr/", "실거래", "deal"), new SourceDefinition("molit-stat", "국토교통 통계누리", "미분양·공급", "molit", "https://stat.molit.go.kr/", "공급", "supply"), new SourceDefinition("bok-rate", "한국은행 일정", "금리·통화정책", "bok", BOK_RATE_SCHEDULE_URL, "금융", "finance"), + new SourceDefinition("bok-stat", "한국은행 경제통계", "금융·거시 통계 공표", "bok", BOK_STAT_SCHEDULE_URL, "금융", "finance"), new SourceDefinition("applyhome", "청약Home", "청약·분양", "applyhome", "https://applyhome.co.kr/", "청약", "subscription"), new SourceDefinition("molit-policy", "국토교통부", "정책·보도자료", "molit", "https://www.molit.go.kr/", "정책", "policy"), - new SourceDefinition("hug-market", "HUG 주택도시보증공사", "보증·주택시장", "hug", "https://www.khug.or.kr/", "보증", "supply") + new SourceDefinition("hug-market", "HUG 주택도시보증공사", "분양가격·분양시장", "hug", HUG_PRIVATE_APT_STAT_URL, "보증", "supply"), + new SourceDefinition("lh-apply", "LH청약플러스", "공공분양·공공임대", "lh", LH_PUBLIC_SALE_LIST_URL, "공급", "supply"), + new SourceDefinition("sh-housing", "서울주거포털 SH", "공공임대", "sh", SH_PUBLIC_LEASE_LIST_URL, "공급", "supply"), + new SourceDefinition("gh-supply", "경기주택도시공사 GH", "분양·임대 공고", "gh", GH_PUBLIC_SUPPLY_LIST_URL, "공급", "supply"), + new SourceDefinition("ih-supply", "인천도시공사 iH", "분양·임대 공고", "ih", IH_RENT_NOTICE_LIST_URL, "공급", "supply"), + new SourceDefinition("fsc-policy", "금융위원회", "부동산 금융정책", "fsc", FSC_PRESS_LIST_URL, "금융", "finance"), + new SourceDefinition("realty-price", "부동산공시가격 알리미", "공시가격", "reb", REALTY_PRICE_BOARD_URL, "공시가격", "market") ); private final MarketDataSourceRepository sourceRepository; @@ -133,7 +246,9 @@ public int refreshMonth(YearMonth month) { Map sourceById = SOURCES.stream() .collect(Collectors.toMap(SourceDefinition::id, Function.identity())); - List seeds = scheduleSeeds(month); + List seeds = scheduleSeeds(month).stream() + .filter(MarketDataScheduleService::hasScheduleContextUrl) + .toList(); Set acceptedIds = seeds.stream() .map(ScheduleSeed::id) .collect(Collectors.toCollection(LinkedHashSet::new)); @@ -217,13 +332,38 @@ private List collectPublishedScheduleSeeds(YearMonth month) { seeds.addAll(collectRebPublishedSeeds(month)); seeds.addAll(collectMolitPolicySeeds(month)); seeds.addAll(collectApplyHomeSeeds(month)); + seeds.addAll(collectHugPublishedSeeds(month)); + seeds.addAll(collectLhPublicSupplySeeds(month)); + seeds.addAll(collectOfficialBoardPublishedSeeds( + month, + "sh-housing", + SH_PUBLIC_LEASE_LIST_URL, + "서울주거포털 SH", + "공급", + "supply", + PUBLIC_SUPPLY_RELEVANT_TITLE_TERMS + )); + seeds.addAll(collectOfficialBoardPublishedSeeds( + month, + "gh-supply", + GH_PUBLIC_SUPPLY_LIST_URL, + "경기주택도시공사 GH", + "공급", + "supply", + PUBLIC_SUPPLY_RELEVANT_TITLE_TERMS + )); + seeds.addAll(collectIhPublicSupplySeeds(month)); + seeds.addAll(collectFscPolicySeeds(month)); + seeds.addAll(collectRealtyPriceNoticeSeeds(month)); return seeds; } private List collectOfficialScheduleSeeds(YearMonth month) { List seeds = new ArrayList<>(); + seeds.addAll(collectRebOfficialScheduleSeeds(month)); seeds.addAll(collectMolitStatScheduleSeeds(month)); seeds.addAll(collectBokRateScheduleSeeds(month)); + seeds.addAll(collectBokStatScheduleSeeds(month)); return seeds; } @@ -279,6 +419,44 @@ private List collectRebPublishedSeeds(YearMonth month) { return seeds; } + private List collectRebOfficialScheduleSeeds(YearMonth month) { + RealEstateExternalFetchResult result = fetchSafely(REB_STAT_SCHEDULE_URL); + if (!result.success() || result.body() == null || result.body().isBlank()) { + return List.of(); + } + + List seeds = new ArrayList<>(); + Matcher cellMatcher = TABLE_CELL_WITH_ID_PATTERN.matcher(result.body()); + while (cellMatcher.find()) { + LocalDate scheduleDate = parseCompactDate(cellMatcher.group(2)); + if (scheduleDate == null || !YearMonth.from(scheduleDate).equals(month)) { + continue; + } + + String cell = cellMatcher.group(3); + Matcher linkMatcher = REB_SCHEDULE_LINK_PATTERN.matcher(cell); + while (linkMatcher.find()) { + String relStatId = cleanHtml(linkMatcher.group(1)); + String title = cleanHtml(linkMatcher.group(2)).replace("새창 열림", "").trim(); + if (isBlank(relStatId) || isBlank(title) || !isRelevantRebOfficialSchedule(title)) { + continue; + } + seeds.add(new ScheduleSeed( + "reb-r-one-schedule-" + relStatId + "-" + scheduleDate, + "reb-r-one", + scheduleDate, + title + " 공표 예정", + rebScheduleCategory(title), + "R-ONE 통계공표일정 " + scheduleDate + " 기준 " + title + " 공식 공표 일정입니다.", + rebScheduleTone(title), + REB_STAT_DETAIL_URL + "?cateId=" + relStatId, + ScheduleSeedKind.OFFICIAL_SCHEDULE + )); + } + } + return seeds; + } + private List collectMolitPolicySeeds(YearMonth month) { RealEstateExternalFetchResult result = fetchSafely(MOLIT_POLICY_LIST_URL); if (!result.success() || result.body() == null || result.body().isBlank()) { @@ -400,25 +578,334 @@ private List collectApplyHomeSeeds(YearMonth month) { continue; } LocalDate noticeDate = parseDate(dates.get(0)); - if (noticeDate == null || !YearMonth.from(noticeDate).equals(month)) { + String detailUrl = APPLYHOME_APT_DETAIL_URL + "?houseManageNo=" + houseManageNo + "&pblancNo=" + pblancNo; + + if (noticeDate != null && YearMonth.from(noticeDate).equals(month)) { + seeds.add(new ScheduleSeed( + "applyhome-published-" + pblancNo, + "applyhome", + noticeDate, + houseName + " 입주자모집공고", + "청약", + applyHomeSummary(dates), + "subscription", + detailUrl, + ScheduleSeedKind.PUBLISHED + )); + } + + if (dates.size() >= 3) { + LocalDate applicationStartDate = parseDate(dates.get(1)); + LocalDate applicationEndDate = parseDate(dates.get(2)); + if (applicationStartDate != null && YearMonth.from(applicationStartDate).equals(month)) { + seeds.add(new ScheduleSeed( + "applyhome-application-start-" + pblancNo + "-" + applicationStartDate, + "applyhome", + applicationStartDate, + houseName + " 청약 접수 시작", + "청약", + "청약Home 공식 공고 기준 접수기간 " + dates.get(1) + "~" + dates.get(2) + " 일정입니다.", + "subscription", + detailUrl, + ScheduleSeedKind.OFFICIAL_SCHEDULE + )); + } + if (applicationEndDate != null && !applicationEndDate.equals(applicationStartDate) + && YearMonth.from(applicationEndDate).equals(month)) { + seeds.add(new ScheduleSeed( + "applyhome-application-end-" + pblancNo + "-" + applicationEndDate, + "applyhome", + applicationEndDate, + houseName + " 청약 접수 마감", + "청약", + "청약Home 공식 공고 기준 접수 마감일 " + dates.get(2) + " 일정입니다.", + "subscription", + detailUrl, + ScheduleSeedKind.OFFICIAL_SCHEDULE + )); + } + } + + if (dates.size() >= 4) { + LocalDate winnerDate = parseDate(dates.get(3)); + if (winnerDate != null && YearMonth.from(winnerDate).equals(month)) { + seeds.add(new ScheduleSeed( + "applyhome-winner-" + pblancNo + "-" + winnerDate, + "applyhome", + winnerDate, + houseName + " 당첨자 발표", + "청약", + "청약Home 공식 공고 기준 당첨자 발표일 " + dates.get(3) + " 일정입니다.", + "subscription", + detailUrl, + ScheduleSeedKind.OFFICIAL_SCHEDULE + )); + } + } + } + return seeds; + } + + private List collectHugPublishedSeeds(YearMonth month) { + RealEstateExternalFetchResult result = fetchSafely(HUG_PRIVATE_APT_STAT_URL); + if (!result.success() || result.body() == null || result.body().isBlank()) { + return List.of(); + } + + List seeds = new ArrayList<>(); + Matcher rowMatcher = TABLE_ROW_PATTERN.matcher(result.body()); + while (rowMatcher.find()) { + String row = rowMatcher.group(2); + Matcher linkMatcher = LINK_PATTERN.matcher(row); + String href = null; + String title = null; + while (linkMatcher.find()) { + String candidateHref = HtmlUtils.htmlUnescape(linkMatcher.group(1)); + String candidateTitle = cleanHtml(linkMatcher.group(2)); + if (!candidateHref.contains("articleId=") || !isRelevantHugTitle(candidateTitle)) { + continue; + } + href = candidateHref; + title = candidateTitle; + break; + } + if (isBlank(href) || isBlank(title)) { + continue; + } + + LocalDate publishedDate = firstDateInMonth(dateStrings(row), month); + if (publishedDate == null) { + continue; + } + + String linkUrl = absoluteUrl(HUG_PRIVATE_APT_STAT_URL, href); + String articleId = queryParam(linkUrl, "articleId"); + String idSuffix = isBlank(articleId) + ? Integer.toUnsignedString((publishedDate + ":" + title + ":" + linkUrl).hashCode(), 36) + : articleId; + seeds.add(new ScheduleSeed( + "hug-market-published-" + idSuffix, + "hug-market", + publishedDate, + title, + "보증", + "HUG 주택도시보증공사 분양시장 공표자료: " + title, + "supply", + linkUrl, + ScheduleSeedKind.PUBLISHED + )); + } + return seeds; + } + + private List collectLhPublicSupplySeeds(YearMonth month) { + List seeds = new ArrayList<>(); + seeds.addAll(collectLhPublicSupplySeeds(month, LH_PUBLIC_SALE_LIST_URL)); + seeds.addAll(collectLhPublicSupplySeeds(month, LH_PUBLIC_RENT_LIST_URL)); + return seeds; + } + + private List collectLhPublicSupplySeeds(YearMonth month, String sourceUrl) { + RealEstateExternalFetchResult result = fetchSafely(sourceUrl); + if (!result.success() || result.body() == null || result.body().isBlank()) { + return List.of(); + } + + List seeds = new ArrayList<>(); + for (String block : htmlBlocks(result.body())) { + String title = firstRelevantText(block, PUBLIC_SUPPLY_RELEVANT_TITLE_TERMS); + if (isBlank(title) || !isRelevantLhPublicSupplyTitle(title)) { + continue; + } + LocalDate publishedDate = firstDateInMonth(dateStrings(block), month); + if (publishedDate == null) { continue; } + String panId = attribute(block, "data-id1"); + String detailUrl = lhPublicSupplyDetailUrl(block); + if (isBlank(panId) || isBlank(detailUrl)) { + continue; + } seeds.add(new ScheduleSeed( - "applyhome-published-" + pblancNo, - "applyhome", - noticeDate, - houseName + " 입주자모집공고", - "청약", - applyHomeSummary(dates), - "subscription", - APPLYHOME_APT_DETAIL_URL + "?houseManageNo=" + houseManageNo + "&pblancNo=" + pblancNo, + "lh-apply-published-" + panId + "-" + publishedDate, + "lh-apply", + publishedDate, + title, + "공급", + "LH청약플러스 공식 공급 공고: " + title, + "supply", + detailUrl, ScheduleSeedKind.PUBLISHED )); } return seeds; } + private List collectIhPublicSupplySeeds(YearMonth month) { + List seeds = new ArrayList<>(); + seeds.addAll(collectOfficialBoardPublishedSeeds( + month, + "ih-supply", + IH_RENT_NOTICE_LIST_URL, + "인천도시공사 iH", + "공급", + "supply", + PUBLIC_SUPPLY_RELEVANT_TITLE_TERMS + )); + seeds.addAll(collectOfficialBoardPublishedSeeds( + month, + "ih-supply", + IH_HOUSE_NOTICE_LIST_URL, + "인천도시공사 iH", + "공급", + "supply", + PUBLIC_SUPPLY_RELEVANT_TITLE_TERMS + )); + return seeds; + } + + private List collectOfficialBoardPublishedSeeds( + YearMonth month, + String sourceId, + String sourceUrl, + String sourceTitle, + String category, + String tone, + List relevantTerms + ) { + RealEstateExternalFetchResult result = fetchSafely(sourceUrl); + if (!result.success() || result.body() == null || result.body().isBlank()) { + return List.of(); + } + + List seeds = new ArrayList<>(); + for (String block : htmlBlocks(result.body())) { + Matcher linkMatcher = LINK_PATTERN.matcher(block); + while (linkMatcher.find()) { + String href = HtmlUtils.htmlUnescape(linkMatcher.group(1)).trim(); + String title = cleanHtml(linkMatcher.group(2)); + if (isPlaceholderHref(href) || isBlank(title) || !relevantTerms.stream().anyMatch(title::contains)) { + continue; + } + if (PUBLIC_SUPPLY_RELEVANT_TITLE_TERMS.equals(relevantTerms) && !isRelevantPublicSupplyTitle(title)) { + continue; + } + + LocalDate publishedDate = firstDateInMonth(dateStrings(block), month); + if (publishedDate == null) { + continue; + } + + String linkUrl = absoluteUrl(sourceUrl, href); + if (isListOnlyUrl(sourceUrl, linkUrl)) { + continue; + } + String idSuffix = boardIdSuffix(linkUrl, publishedDate, title); + seeds.add(new ScheduleSeed( + sourceId + "-published-" + idSuffix + "-" + publishedDate, + sourceId, + publishedDate, + title, + category, + sourceTitle + " 공식 공급 공고: " + title, + tone, + linkUrl, + ScheduleSeedKind.PUBLISHED + )); + break; + } + } + return seeds; + } + + private List collectFscPolicySeeds(YearMonth month) { + RealEstateExternalFetchResult result = fetchSafely(FSC_PRESS_LIST_URL); + if (!result.success() || result.body() == null || result.body().isBlank()) { + return List.of(); + } + + List seeds = new ArrayList<>(); + for (String block : htmlBlocks(result.body())) { + Matcher linkMatcher = LINK_PATTERN.matcher(block); + while (linkMatcher.find()) { + String href = HtmlUtils.htmlUnescape(linkMatcher.group(1)).trim(); + String title = cleanHtml(linkMatcher.group(2)); + if (!href.contains("/no010101/") || !isRelevantFinancePolicyTitle(title)) { + continue; + } + + LocalDate publishedDate = firstDateInMonth(dateStrings(block), month); + if (publishedDate == null) { + continue; + } + + String linkUrl = absoluteUrl(FSC_PRESS_LIST_URL, href); + String articleId = pathNumberAfter(linkUrl, "/no010101/"); + String idSuffix = isBlank(articleId) ? boardIdSuffix(linkUrl, publishedDate, title) : articleId; + seeds.add(new ScheduleSeed( + "fsc-policy-published-" + idSuffix, + "fsc-policy", + publishedDate, + title, + "금융", + "금융위원회 부동산 금융정책 보도자료: " + title, + "finance", + linkUrl, + ScheduleSeedKind.PUBLISHED + )); + break; + } + } + return seeds; + } + + private List collectRealtyPriceNoticeSeeds(YearMonth month) { + RealEstateExternalFetchResult result = fetchSafely(REALTY_PRICE_BOARD_URL); + if (!result.success() || result.body() == null || result.body().isBlank()) { + return List.of(); + } + + List seeds = new ArrayList<>(); + for (String block : htmlBlocks(result.body())) { + Matcher linkMatcher = LINK_PATTERN.matcher(block); + while (linkMatcher.find()) { + String href = HtmlUtils.htmlUnescape(linkMatcher.group(1)).trim(); + String title = cleanHtml(linkMatcher.group(2)); + if (isBlank(title) || !isRelevantOfficialPriceTitle(title)) { + continue; + } + + LocalDate publishedDate = firstDateInMonth(dateStrings(block), month); + if (publishedDate == null) { + continue; + } + + String detailSeq = realtyPriceDetailSeq(href); + String linkUrl = isBlank(detailSeq) + ? absoluteUrl(REALTY_PRICE_BOARD_URL, href) + : REALTY_PRICE_DETAIL_URL + "?seq=" + detailSeq; + if (isListOnlyUrl(REALTY_PRICE_BOARD_URL, linkUrl)) { + continue; + } + String idSuffix = isBlank(detailSeq) ? boardIdSuffix(linkUrl, publishedDate, title) : detailSeq; + seeds.add(new ScheduleSeed( + "realty-price-published-" + idSuffix, + "realty-price", + publishedDate, + title, + "공시가격", + "부동산공시가격 알리미 공식 공지: " + title, + "market", + linkUrl, + ScheduleSeedKind.PUBLISHED + )); + break; + } + } + return seeds; + } + private List collectBokRateScheduleSeeds(YearMonth month) { RealEstateExternalFetchResult result = fetchSafely(BOK_RATE_SCHEDULE_URL); if (!result.success() || result.body() == null || result.body().isBlank()) { @@ -456,6 +943,42 @@ private List collectBokRateScheduleSeeds(YearMonth month) { .toList(); } + private List collectBokStatScheduleSeeds(YearMonth month) { + String scheduleUrl = bokStatScheduleUrl(month); + RealEstateExternalFetchResult result = fetchSafely(scheduleUrl); + if (!result.success() || result.body() == null || result.body().isBlank()) { + return List.of(); + } + + List seeds = new ArrayList<>(); + Matcher rowMatcher = TABLE_ROW_PATTERN.matcher(result.body()); + while (rowMatcher.find()) { + List cells = tableCells(rowMatcher.group(2)); + if (cells.size() < 3) { + continue; + } + LocalDate scheduleDate = parseDate(cells.get(0)); + String publishTime = cells.get(1); + String title = cells.get(2); + if (scheduleDate == null || !YearMonth.from(scheduleDate).equals(month) || !isRelevantBokStatTitle(title)) { + continue; + } + + seeds.add(new ScheduleSeed( + "bok-stat-schedule-" + Integer.toUnsignedString((scheduleDate + ":" + publishTime + ":" + title).hashCode(), 36), + "bok-stat", + scheduleDate, + title + " 공표 예정", + "금융", + "한국은행 월간통계 공표일정 기준 " + scheduleDate + " " + publishTime + " 공개 예정인 금융·거시 통계입니다.", + "finance", + scheduleUrl, + ScheduleSeedKind.OFFICIAL_SCHEDULE + )); + } + return seeds; + } + private String scheduleStatus(ScheduleSeed seed, SourceCheck check) { if (seed.kind() == ScheduleSeedKind.PUBLISHED) { return "공표 확인"; @@ -507,12 +1030,199 @@ private static boolean isRelevantMolitStat(String title) { return MOLIT_STAT_RELEVANT_TITLE_TERMS.stream().anyMatch(title::contains); } + private static boolean isRelevantRebOfficialSchedule(String title) { + return title.contains("주택가격") + || title.contains("아파트가격") + || title.contains("오피스텔가격") + || title.contains("공동주택실거래가격지수") + || title.contains("부동산거래"); + } + + private static String rebScheduleCategory(String title) { + if (title.contains("부동산거래")) { + return "거래현황"; + } + if (title.contains("오피스텔")) { + return "가격지수"; + } + if (title.contains("실거래가격지수")) { + return "가격지수"; + } + return "가격지수"; + } + + private static String rebScheduleTone(String title) { + if (title.contains("부동산거래")) { + return "deal"; + } + return "market"; + } + + private static boolean isRelevantHugTitle(String title) { + return HUG_RELEVANT_TITLE_TERMS.stream().anyMatch(title::contains); + } + + private static boolean isRelevantPublicSupplyTitle(String title) { + String compactTitle = title.replace(" ", ""); + boolean housingSupply = PUBLIC_SUPPLY_RELEVANT_TITLE_TERMS.stream().anyMatch(title::contains); + if (!housingSupply) { + return false; + } + boolean nonHousingSupply = compactTitle.contains("상가") + || compactTitle.contains("토지") + || compactTitle.contains("산업단지") + || compactTitle.contains("용지") + || compactTitle.contains("채용") + || compactTitle.contains("입찰"); + return !nonHousingSupply || compactTitle.contains("주택"); + } + + private static boolean isRelevantLhPublicSupplyTitle(String title) { + String compactTitle = title.replace(" ", ""); + if (Set.of("분양주택", "임대주택", "공공분양", "공공임대", "공급공고").contains(compactTitle)) { + return false; + } + if (LH_LOW_SIGNAL_TITLE_TERMS.stream().map(term -> term.replace(" ", "")).anyMatch(compactTitle::contains)) { + return false; + } + return LH_MAJOR_SUPPLY_TITLE_TERMS.stream().anyMatch(title::contains); + } + + private static boolean isRelevantFinancePolicyTitle(String title) { + String upperTitle = title.toUpperCase(); + return FINANCE_POLICY_RELEVANT_TITLE_TERMS.stream() + .anyMatch(term -> upperTitle.contains(term.toUpperCase())); + } + + private static boolean isRelevantBokStatTitle(String title) { + return BOK_STAT_RELEVANT_TITLE_TERMS.stream().anyMatch(title::contains); + } + + private static boolean isRelevantOfficialPriceTitle(String title) { + return title.contains("공동주택가격") + || (title.contains("공동주택") && OFFICIAL_PRICE_RELEVANT_TITLE_TERMS.stream().anyMatch(title::contains)); + } + + private static List htmlBlocks(String body) { + LinkedHashSet blocks = new LinkedHashSet<>(); + Matcher rowMatcher = TABLE_ROW_PATTERN.matcher(body); + while (rowMatcher.find()) { + blocks.add(rowMatcher.group(2)); + } + Matcher itemMatcher = LIST_ITEM_PATTERN.matcher(body); + while (itemMatcher.find()) { + blocks.add(itemMatcher.group(1)); + } + if (blocks.isEmpty()) { + blocks.add(body); + } + return new ArrayList<>(blocks); + } + + private static String firstRelevantText(String block, List relevantTerms) { + Matcher linkMatcher = LINK_PATTERN.matcher(block); + while (linkMatcher.find()) { + String candidate = cleanHtml(linkMatcher.group(2)); + if (!isBlank(candidate) && relevantTerms.stream().anyMatch(candidate::contains)) { + return candidate; + } + } + + Matcher cellMatcher = TABLE_CELL_PATTERN.matcher(block); + while (cellMatcher.find()) { + String candidate = cleanHtml(cellMatcher.group(1)); + if (!isBlank(candidate) && relevantTerms.stream().anyMatch(candidate::contains)) { + return candidate; + } + } + + String text = cleanHtml(block); + return relevantTerms.stream().anyMatch(text::contains) ? text : ""; + } + + private static String lhPublicSupplyDetailUrl(String block) { + String panId = attribute(block, "data-id1"); + if (isBlank(panId)) { + return ""; + } + StringBuilder detailUrl = new StringBuilder(LH_PUBLIC_DETAIL_URL) + .append("?panId=") + .append(panId); + appendQueryParam(detailUrl, "ccrCnntSysDsCd", attribute(block, "data-id2")); + appendQueryParam(detailUrl, "uppAisTpCd", attribute(block, "data-id3")); + appendQueryParam(detailUrl, "aisTpCd", attribute(block, "data-id4")); + return detailUrl.toString(); + } + + private static void appendQueryParam(StringBuilder url, String name, String value) { + if (!isBlank(value)) { + url.append('&').append(name).append('=').append(value); + } + } + + private static boolean isPlaceholderHref(String href) { + if (isBlank(href)) { + return true; + } + String normalized = href.trim().toLowerCase(); + return normalized.startsWith("#") + || normalized.equals("javascript:void(0)") + || normalized.equals("javascript:void(0);") + || normalized.equals("javascript:;"); + } + + private static boolean isListOnlyUrl(String sourceUrl, String linkUrl) { + URI source = URI.create(sourceUrl); + URI link = URI.create(linkUrl); + return source.getHost().equals(link.getHost()) + && source.getPath().equals(link.getPath()) + && isBlank(link.getRawQuery()) + && isBlank(link.getFragment()); + } + + private static String boardIdSuffix(String linkUrl, LocalDate publishedDate, String title) { + for (String name : List.of("seq", "articleNo", "bbscttSn", "nttSn", "no", "id")) { + String value = queryParam(linkUrl, name); + if (!isBlank(value)) { + return value; + } + } + + Matcher lastNumberMatcher = Pattern.compile("(\\d+)(?!.*\\d)").matcher(URI.create(linkUrl).getPath()); + if (lastNumberMatcher.find()) { + return lastNumberMatcher.group(1); + } + return Integer.toUnsignedString((publishedDate + ":" + title + ":" + linkUrl).hashCode(), 36); + } + + private static String pathNumberAfter(String url, String segment) { + Matcher matcher = Pattern.compile(Pattern.quote(segment) + "(\\d+)").matcher(url); + return matcher.find() ? matcher.group(1) : ""; + } + + private static String realtyPriceDetailSeq(String href) { + Matcher matcher = REALTY_PRICE_DETAIL_PATTERN.matcher(href); + return matcher.find() ? matcher.group(1) : ""; + } + private static String classCell(String row, String className) { Pattern pattern = Pattern.compile("(?is)]*class=\"[^\"]*\\b" + className + "\\b[^\"]*\"[^>]*>(.*?)"); Matcher matcher = pattern.matcher(row); return matcher.find() ? matcher.group(1) : ""; } + private static List tableCells(String row) { + List cells = new ArrayList<>(); + Matcher cellMatcher = TABLE_CELL_PATTERN.matcher(row); + while (cellMatcher.find()) { + String value = cleanHtml(cellMatcher.group(1)); + if (!value.isBlank()) { + cells.add(value); + } + } + return cells; + } + private static String attribute(String attributes, String name) { Pattern pattern = Pattern.compile("(?is)\\b" + name + "\\s*=\\s*\"([^\"]*)\""); Matcher matcher = pattern.matcher(attributes); @@ -520,12 +1230,20 @@ private static String attribute(String attributes, String name) { } private static List dateStrings(String value) { - List dates = new ArrayList<>(); - Matcher matcher = ISO_DATE_PATTERN.matcher(value); - while (matcher.find()) { - dates.add(matcher.group()); + LinkedHashSet dates = new LinkedHashSet<>(); + Matcher isoMatcher = ISO_DATE_PATTERN.matcher(value); + while (isoMatcher.find()) { + dates.add(isoMatcher.group()); } - return dates; + Matcher dotMatcher = DOT_DATE_PATTERN.matcher(value); + while (dotMatcher.find()) { + dates.add(normalizedDate(dotMatcher.group(1), dotMatcher.group(2), dotMatcher.group(3))); + } + Matcher shortDotMatcher = SHORT_DOT_DATE_PATTERN.matcher(value); + while (shortDotMatcher.find()) { + dates.add(normalizedDate("20" + shortDotMatcher.group(1), shortDotMatcher.group(2), shortDotMatcher.group(3))); + } + return new ArrayList<>(dates); } private static LocalDate firstDateInMonth(List values, YearMonth month) { @@ -549,6 +1267,21 @@ private static LocalDate parseDate(String value) { return LocalDate.parse(matcher.group()); } + private static LocalDate parseCompactDate(String value) { + if (isBlank(value) || value.length() != 8) { + return null; + } + return LocalDate.of( + Integer.parseInt(value.substring(0, 4)), + Integer.parseInt(value.substring(4, 6)), + Integer.parseInt(value.substring(6, 8)) + ); + } + + private static String normalizedDate(String year, String month, String day) { + return String.format("%s-%02d-%02d", year, Integer.parseInt(month), Integer.parseInt(day)); + } + private static String applyHomeSummary(List dates) { String noticeDate = dates.getFirst(); if (dates.size() >= 4) { @@ -593,6 +1326,10 @@ private static String absoluteUrl(String baseUrl, String href) { return URI.create(baseUrl).resolve(href).toString(); } + private static String bokStatScheduleUrl(YearMonth month) { + return "https://www.bok.or.kr/portal/stats/statsPublictSchdul/listCldr.do?date=" + month + "&menuNo=200775"; + } + private static String queryParam(String url, String name) { String query = URI.create(url).getRawQuery(); if (query == null) { @@ -611,6 +1348,31 @@ private static String queryParam(String url, String name) { return ""; } + private static boolean hasScheduleContextUrl(ScheduleSeed seed) { + if (isBlank(seed.linkUrl())) { + return false; + } + String url = seed.linkUrl(); + if ("bok-rate".equals(seed.sourceId()) && BOK_RATE_SCHEDULE_URL.equals(url)) { + return true; + } + if ("bok-stat".equals(seed.sourceId()) && url.startsWith("https://www.bok.or.kr/portal/stats/statsPublictSchdul/listCldr.do?date=")) { + return true; + } + return url.contains("selectBulletinPage.do") + || url.contains("easyStatPage.do?cateId=") + || url.contains("viewChk.do") + || url.contains("selectAPTLttotPblancDetail.do") + || url.contains("selectWrtancInfo.do") + || url.contains("articleId=") + || url.contains("/site/main/sh/publicLease/07/view") + || url.contains("announcement-of-salerental001.do?articleNo=") + || url.contains("/main/sale_lease/notice_view.jsp") + || url.contains("/USR/NEWS/") + || url.contains("/no010101/") + || url.contains("boardDetailAll.board?seq="); + } + private static boolean isBlank(String value) { return value == null || value.isBlank(); } @@ -642,6 +1404,7 @@ private MarketDataSourceLinkResponse toSourceResponse(MarketDataSource source) { source.getSourceUrl(), source.getProvider(), source.getStatus(), + source.isStale() ? "error" : "ok", source.isStale(), source.getLastCheckedAt() ); diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReport.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReport.java new file mode 100644 index 0000000..bf72493 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReport.java @@ -0,0 +1,211 @@ +package com.youbuyfirst.backend.realestate; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "real_estate_regional_reports") +public class RealEstateRegionalReport { + + @Id + @Column(name = "target_id", length = 120) + private String targetId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "target_id", insertable = false, updatable = false) + private RealEstateTarget target; + + @Column(name = "report_id", nullable = false, length = 160) + private String reportId; + + @Column(name = "report_version", nullable = false, length = 80) + private String reportVersion; + + @Column(name = "prompt_version", length = 80) + private String promptVersion; + + @Column(name = "model_name", length = 80) + private String modelName; + + @Column(name = "generated_by", nullable = false, length = 80) + private String generatedBy; + + @Column(nullable = false, length = 200) + private String title; + + @Column(nullable = false, length = 300) + private String headline; + + @Column(nullable = false, columnDefinition = "text") + private String summary; + + @Column(nullable = false, columnDefinition = "text") + private String body; + + @Column(name = "expectation_points_json", nullable = false, columnDefinition = "text") + private String expectationPointsJson; + + @Column(name = "concern_points_json", nullable = false, columnDefinition = "text") + private String concernPointsJson; + + @Column(name = "data_quality", nullable = false, length = 30) + private String dataQuality; + + @Column + private Double confidence; + + @Column(name = "as_of", nullable = false) + private Instant asOf; + + @Column(name = "published_at", nullable = false) + private Instant publishedAt; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @OrderBy("displayOrder asc, id asc") + @OneToMany(mappedBy = "report", cascade = CascadeType.ALL, orphanRemoval = true) + private final List sources = new ArrayList<>(); + + protected RealEstateRegionalReport() { + } + + public RealEstateRegionalReport(String targetId, Instant now) { + this.targetId = targetId; + this.createdAt = now; + } + + public void update( + RealEstateTarget target, + String reportId, + String reportVersion, + String promptVersion, + String modelName, + String generatedBy, + String title, + String headline, + String summary, + String body, + String expectationPointsJson, + String concernPointsJson, + String dataQuality, + Double confidence, + Instant asOf, + Instant publishedAt, + List nextSources, + Instant now + ) { + this.targetId = target.getId(); + this.target = target; + this.reportId = reportId; + this.reportVersion = reportVersion; + this.promptVersion = promptVersion; + this.modelName = modelName; + this.generatedBy = generatedBy; + this.title = title; + this.headline = headline; + this.summary = summary; + this.body = body; + this.expectationPointsJson = expectationPointsJson; + this.concernPointsJson = concernPointsJson; + this.dataQuality = dataQuality; + this.confidence = confidence; + this.asOf = asOf; + this.publishedAt = publishedAt; + this.sources.clear(); + nextSources.forEach(this::addSource); + this.updatedAt = now; + } + + private void addSource(RealEstateRegionalReportSource source) { + source.attachTo(this); + this.sources.add(source); + } + + public String getTargetId() { + return targetId; + } + + public RealEstateTarget getTarget() { + return target; + } + + public String getReportId() { + return reportId; + } + + public String getReportVersion() { + return reportVersion; + } + + public String getPromptVersion() { + return promptVersion; + } + + public String getModelName() { + return modelName; + } + + public String getGeneratedBy() { + return generatedBy; + } + + public String getTitle() { + return title; + } + + public String getHeadline() { + return headline; + } + + public String getSummary() { + return summary; + } + + public String getBody() { + return body; + } + + public String getExpectationPointsJson() { + return expectationPointsJson; + } + + public String getConcernPointsJson() { + return concernPointsJson; + } + + public String getDataQuality() { + return dataQuality; + } + + public Double getConfidence() { + return confidence; + } + + public Instant getAsOf() { + return asOf; + } + + public Instant getPublishedAt() { + return publishedAt; + } + + public List getSources() { + return sources; + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportController.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportController.java new file mode 100644 index 0000000..b8b55a9 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportController.java @@ -0,0 +1,33 @@ +package com.youbuyfirst.backend.realestate; + +import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportBatchRequest; +import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportBatchResponse; +import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportResponse; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class RealEstateRegionalReportController { + + private final RealEstateRegionalReportService service; + + public RealEstateRegionalReportController(RealEstateRegionalReportService service) { + this.service = service; + } + + @PostMapping("/internal/realestate/regional-reports") + public RealEstateRegionalReportBatchResponse upsertRegionalReports( + @Valid @RequestBody RealEstateRegionalReportBatchRequest request + ) { + return service.upsertAll(request.reports()); + } + + @GetMapping("/api/realestate/targets/{targetId}/regional-report") + public RealEstateRegionalReportResponse targetRegionalReport(@PathVariable String targetId) { + return service.latestForTarget(targetId); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportRepository.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportRepository.java new file mode 100644 index 0000000..0c10413 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportRepository.java @@ -0,0 +1,6 @@ +package com.youbuyfirst.backend.realestate; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RealEstateRegionalReportRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportService.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportService.java new file mode 100644 index 0000000..32d1321 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportService.java @@ -0,0 +1,232 @@ +package com.youbuyfirst.backend.realestate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportBatchResponse; +import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportRequest; +import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportResponse; +import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportSourceRequest; +import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportSourceResponse; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Locale; + +@Service +public class RealEstateRegionalReportService { + + private static final TypeReference> STRING_LIST = new TypeReference<>() { + }; + + private final RealEstateRegionalReportRepository reportRepository; + private final RealEstateTargetRepository targetRepository; + private final RealEstateRegionRepository regionRepository; + private final ObjectMapper objectMapper; + + public RealEstateRegionalReportService( + RealEstateRegionalReportRepository reportRepository, + RealEstateTargetRepository targetRepository, + RealEstateRegionRepository regionRepository, + ObjectMapper objectMapper + ) { + this.reportRepository = reportRepository; + this.targetRepository = targetRepository; + this.regionRepository = regionRepository; + this.objectMapper = objectMapper; + } + + @Transactional + public RealEstateRegionalReportBatchResponse upsertAll(Collection requests) { + Instant now = Instant.now(); + int acceptedReports = 0; + int acceptedSources = 0; + for (RealEstateRegionalReportRequest request : requests == null ? List.of() : requests) { + RealEstateTarget target = targetRepository.findById(requireText(request.targetId(), "targetId")) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "unknown real-estate target: " + request.targetId() + )); + List sources = toSources(target.getId(), request.sources(), now); + RealEstateRegionalReport report = reportRepository.findById(target.getId()) + .orElseGet(() -> new RealEstateRegionalReport(target.getId(), now)); + report.update( + target, + requireText(request.reportId(), "reportId"), + requireText(request.reportVersion(), "reportVersion"), + trimToNull(request.promptVersion()), + trimToNull(request.modelName()), + requireText(request.generatedBy(), "generatedBy"), + requireText(request.title(), "title"), + requireText(request.headline(), "headline"), + requireText(request.summary(), "summary"), + requireText(request.body(), "body"), + writeStringList(request.expectationPoints()), + writeStringList(request.concernPoints()), + normalizeLower(requireText(request.dataQuality(), "dataQuality")), + clampConfidence(request.confidence()), + requireInstant(request.asOf(), "asOf"), + requireInstant(request.publishedAt(), "publishedAt"), + sources, + now + ); + reportRepository.save(report); + acceptedReports++; + acceptedSources += sources.size(); + } + return new RealEstateRegionalReportBatchResponse(acceptedReports, acceptedSources); + } + + @Transactional(readOnly = true) + public RealEstateRegionalReportResponse latestForTarget(String targetId) { + String normalizedTargetId = requireText(targetId, "targetId"); + targetRepository.findById(normalizedTargetId) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "unknown real-estate target: " + normalizedTargetId + )); + RealEstateRegionalReport report = reportRepository.findById(normalizedTargetId) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, + "regional report is not ready for target: " + normalizedTargetId + )); + return toResponse(report); + } + + private List toSources( + String targetId, + List requests, + Instant now + ) { + List safeRequests = requests == null ? List.of() : requests; + return java.util.stream.IntStream.range(0, safeRequests.size()) + .mapToObj(index -> toSource(targetId, safeRequests.get(index), index + 1, now)) + .toList(); + } + + private RealEstateRegionalReportSource toSource( + String targetId, + RealEstateRegionalReportSourceRequest request, + int displayOrder, + Instant now + ) { + return new RealEstateRegionalReportSource( + defaultIfBlank(request.reportSourceId(), "regional-report-source-%s-%02d".formatted(targetId, displayOrder)), + displayOrder, + normalizeLower(requireText(request.refType(), "refType")), + trimToNull(request.refId()), + requireText(request.label(), "label"), + requireText(request.title(), "title"), + trimToNull(request.url()), + trimToNull(request.sourceName()), + request.publishedAt(), + normalizeLower(requireText(request.dataStatus(), "dataStatus")), + now + ); + } + + private RealEstateRegionalReportResponse toResponse(RealEstateRegionalReport report) { + RealEstateRegion region = regionRepository.findById(report.getTargetId()).orElse(null); + RealEstateTarget target = report.getTarget(); + return new RealEstateRegionalReportResponse( + report.getReportId(), + report.getTargetId(), + target == null ? report.getTargetId() : target.getDisplayName(), + region == null ? null : region.getRegionLevel(), + region == null ? null : region.getRegionCode(), + report.getReportVersion(), + report.getPromptVersion(), + report.getModelName(), + report.getGeneratedBy(), + report.getTitle(), + report.getHeadline(), + report.getSummary(), + report.getBody(), + readStringList(report.getExpectationPointsJson()), + readStringList(report.getConcernPointsJson()), + report.getDataQuality(), + report.getConfidence(), + report.getAsOf(), + report.getPublishedAt(), + report.getSources().stream().map(this::toSourceResponse).toList() + ); + } + + private RealEstateRegionalReportSourceResponse toSourceResponse(RealEstateRegionalReportSource source) { + return new RealEstateRegionalReportSourceResponse( + source.getId(), + source.getDisplayOrder(), + source.getRefType(), + source.getRefId(), + source.getLabel(), + source.getTitle(), + source.getUrl(), + source.getSourceName(), + source.getPublishedAt(), + source.getDataStatus() + ); + } + + private String writeStringList(List values) { + try { + List normalized = (values == null ? List.of() : values).stream() + .map(RealEstateRegionalReportService::trimToNull) + .filter(value -> value != null) + .toList(); + return objectMapper.writeValueAsString(normalized); + } catch (JsonProcessingException exc) { + throw new IllegalArgumentException("invalid regional report points", exc); + } + } + + private List readStringList(String value) { + try { + return objectMapper.readValue(value, STRING_LIST); + } catch (JsonProcessingException exc) { + throw new IllegalStateException("invalid stored regional report points", exc); + } + } + + private static Instant requireInstant(Instant value, String fieldName) { + if (value == null) { + throw new IllegalArgumentException(fieldName + " is required"); + } + return value; + } + + private static String requireText(String value, String fieldName) { + String trimmed = trimToNull(value); + if (trimmed == null) { + throw new IllegalArgumentException(fieldName + " is required"); + } + return trimmed; + } + + private static String defaultIfBlank(String value, String fallback) { + String trimmed = trimToNull(value); + return trimmed == null ? fallback : trimmed; + } + + private static String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private static String normalizeLower(String value) { + return value.trim().toLowerCase(Locale.ROOT); + } + + private static Double clampConfidence(Double value) { + if (value == null) { + return null; + } + return Math.max(0.0, Math.min(1.0, value)); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportSource.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportSource.java new file mode 100644 index 0000000..c5b717b --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportSource.java @@ -0,0 +1,131 @@ +package com.youbuyfirst.backend.realestate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.Instant; + +@Entity +@Table(name = "real_estate_regional_report_sources") +public class RealEstateRegionalReportSource { + + @Id + @Column(length = 160) + private String id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "report_target_id", nullable = false, referencedColumnName = "target_id") + private RealEstateRegionalReport report; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Column(name = "ref_type", nullable = false, length = 60) + private String refType; + + @Column(name = "ref_id", length = 160) + private String refId; + + @Column(nullable = false, length = 120) + private String label; + + @Column(nullable = false, length = 240) + private String title; + + @Column(columnDefinition = "text") + private String url; + + @Column(name = "source_name", length = 160) + private String sourceName; + + @Column(name = "published_at") + private Instant publishedAt; + + @Column(name = "data_status", nullable = false, length = 30) + private String dataStatus; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected RealEstateRegionalReportSource() { + } + + public RealEstateRegionalReportSource( + String id, + int displayOrder, + String refType, + String refId, + String label, + String title, + String url, + String sourceName, + Instant publishedAt, + String dataStatus, + Instant now + ) { + this.id = id; + this.displayOrder = displayOrder; + this.refType = refType; + this.refId = refId; + this.label = label; + this.title = title; + this.url = url; + this.sourceName = sourceName; + this.publishedAt = publishedAt; + this.dataStatus = dataStatus; + this.createdAt = now; + this.updatedAt = now; + } + + void attachTo(RealEstateRegionalReport report) { + this.report = report; + } + + public String getId() { + return id; + } + + public int getDisplayOrder() { + return displayOrder; + } + + public String getRefType() { + return refType; + } + + public String getRefId() { + return refId; + } + + public String getLabel() { + return label; + } + + public String getTitle() { + return title; + } + + public String getUrl() { + return url; + } + + public String getSourceName() { + return sourceName; + } + + public Instant getPublishedAt() { + return publishedAt; + } + + public String getDataStatus() { + return dataStatus; + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetController.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetController.java index 4c8c702..ee5e641 100644 --- a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetController.java +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetController.java @@ -24,9 +24,10 @@ public RealEstateTargetController(RealEstateTargetService service) { @GetMapping("/api/realestate/targets/search") public RealEstateTargetListResponse search( @RequestParam(required = false) String q, - @RequestParam(defaultValue = "20") int limit + @RequestParam(defaultValue = "20") int limit, + @RequestParam(defaultValue = "default") String mode ) { - return new RealEstateTargetListResponse(service.search(q, limit)); + return new RealEstateTargetListResponse(service.search(q, limit, "autocomplete".equalsIgnoreCase(mode))); } @GetMapping("/internal/realestate/market-data-targets") diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetRepository.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetRepository.java index 137f1e9..6ef7d33 100644 --- a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetRepository.java +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetRepository.java @@ -82,4 +82,216 @@ List searchTargets( @Param("rawQuery") String rawQuery, Pageable pageable ); + + @Query(value = """ + select + target.id as targetId, + target.target_type as targetType, + target.display_name as displayName, + target.slug as slug, + target.review_state as reviewState, + target.data_status as dataStatus, + case when target.target_type = 'complex' then 'complex' else region.region_level end as regionLevel, + case when target.target_type = 'complex' then complex.region_target_id else region.parent_region_id end as parentTargetId, + case when target.target_type = 'complex' then coalesce(complex.legal_dong_code, parent_region.legal_dong_code) else region.legal_dong_code end as legalDongCode, + case when target.target_type = 'complex' then parent_region.region_code else region.region_code end as regionCode + from ( + select matched.target_id, min(matched.score) as score + from ( + select exact_target.id as target_id, 0 as score + from real_estate_targets exact_target + where exact_target.normalized_name = :normalizedQuery + or exact_target.slug = :normalizedQuery + or exact_target.id = :normalizedQuery + union all + select exact_region.target_id as target_id, 0 as score + from real_estate_regions exact_region + where exact_region.legal_dong_code = :rawQuery + union all + select exact_complex.target_id as target_id, 0 as score + from real_estate_complexes exact_complex + where exact_complex.legal_dong_code = :rawQuery + union all + select exact_alias.target_id as target_id, 0 as score + from real_estate_aliases exact_alias + where exact_alias.review_state = 'approved' + and exact_alias.ambiguous = false + and exact_alias.normalized_alias = :aliasQuery + union all + select prefix_target.id as target_id, 1 as score + from real_estate_targets prefix_target + where prefix_target.normalized_name like concat(:normalizedQuery, '%') + or prefix_target.slug like concat(:normalizedQuery, '%') + or prefix_target.id like concat(:normalizedQuery, '%') + union all + select prefix_region.target_id as target_id, 1 as score + from real_estate_regions prefix_region + where prefix_region.legal_dong_code like concat(:rawQuery, '%') + union all + select prefix_complex.target_id as target_id, 1 as score + from real_estate_complexes prefix_complex + where prefix_complex.legal_dong_code like concat(:rawQuery, '%') + union all + select prefix_alias.target_id as target_id, 1 as score + from real_estate_aliases prefix_alias + where prefix_alias.review_state = 'approved' + and prefix_alias.ambiguous = false + and prefix_alias.normalized_alias like concat(:aliasQuery, '%') + union all + select contains_region_target.id as target_id, 1 as score + from real_estate_targets contains_region_target + where contains_region_target.target_type = 'region' + and contains_region_target.normalized_name like concat('%', :normalizedQuery, '%') + ) matched + group by matched.target_id + ) ranked + join real_estate_targets target on target.id = ranked.target_id + left join real_estate_regions region on region.target_id = target.id + left join real_estate_complexes complex on complex.target_id = target.id + left join real_estate_regions parent_region on parent_region.target_id = complex.region_target_id + order by + ranked.score asc, + case + when target.target_type = 'region' then 0 + when target.target_type = 'complex' then 1 + else 2 + end asc, + case + when target.data_status = 'ok' then 0 + when target.data_status = 'partial' then 1 + when target.data_status = 'candidate' then 2 + when target.data_status = 'mock' then 3 + else 4 + end asc, + target.display_name asc + """, nativeQuery = true) + List searchTargetsIndexed( + @Param("normalizedQuery") String normalizedQuery, + @Param("aliasQuery") String aliasQuery, + @Param("rawQuery") String rawQuery, + Pageable pageable + ); + + @Query(value = """ + select + target.id as targetId, + target.target_type as targetType, + target.display_name as displayName, + target.slug as slug, + target.review_state as reviewState, + target.data_status as dataStatus, + case when target.target_type = 'complex' then 'complex' else region.region_level end as regionLevel, + case when target.target_type = 'complex' then complex.region_target_id else region.parent_region_id end as parentTargetId, + case when target.target_type = 'complex' then coalesce(complex.legal_dong_code, parent_region.legal_dong_code) else region.legal_dong_code end as legalDongCode, + case when target.target_type = 'complex' then parent_region.region_code else region.region_code end as regionCode + from ( + select matched.target_id, min(matched.score) as score + from ( + select exact_target.id as target_id, 0 as score + from real_estate_targets exact_target + left join real_estate_regions exact_region on exact_region.target_id = exact_target.id + left join real_estate_complexes exact_complex on exact_complex.target_id = exact_target.id + where exact_target.normalized_name = :normalizedQuery + or exact_target.slug = :normalizedQuery + or exact_target.id = :normalizedQuery + or exact_region.legal_dong_code = :rawQuery + or exact_complex.legal_dong_code = :rawQuery + union all + select exact_alias.target_id as target_id, 0 as score + from real_estate_aliases exact_alias + where exact_alias.review_state = 'approved' + and exact_alias.ambiguous = false + and exact_alias.normalized_alias = :aliasQuery + union all + select prefix_target.id as target_id, 1 as score + from real_estate_targets prefix_target + left join real_estate_regions prefix_region on prefix_region.target_id = prefix_target.id + left join real_estate_complexes prefix_complex on prefix_complex.target_id = prefix_target.id + where prefix_target.display_name like concat(:query, '%') + or prefix_target.normalized_name like concat(:normalizedQuery, '%') + or prefix_target.slug like concat(:normalizedQuery, '%') + or prefix_target.id like concat(:normalizedQuery, '%') + or prefix_region.legal_dong_code like concat(:rawQuery, '%') + or prefix_complex.legal_dong_code like concat(:rawQuery, '%') + union all + select prefix_alias.target_id as target_id, 1 as score + from real_estate_aliases prefix_alias + where prefix_alias.review_state = 'approved' + and prefix_alias.ambiguous = false + and ( + prefix_alias.alias like concat(:query, '%') + or prefix_alias.normalized_alias like concat(:aliasQuery, '%') + ) + union all + select contains_target.id as target_id, 2 as score + from real_estate_targets contains_target + left join real_estate_regions contains_region on contains_region.target_id = contains_target.id + left join real_estate_complexes contains_complex on contains_complex.target_id = contains_target.id + where contains_target.display_name like concat('%', :query, '%') + or contains_target.normalized_name like concat('%', :normalizedQuery, '%') + or contains_target.slug like concat('%', :normalizedQuery, '%') + or contains_target.id like concat('%', :normalizedQuery, '%') + or contains_region.legal_dong_code = :rawQuery + or contains_complex.legal_dong_code = :rawQuery + union all + select contains_alias.target_id as target_id, 2 as score + from real_estate_aliases contains_alias + where contains_alias.review_state = 'approved' + and contains_alias.ambiguous = false + and ( + contains_alias.alias like concat('%', :query, '%') + or contains_alias.normalized_alias like concat('%', :aliasQuery, '%') + ) + ) matched + group by matched.target_id + ) ranked + join real_estate_targets target on target.id = ranked.target_id + left join real_estate_regions region on region.target_id = target.id + left join real_estate_complexes complex on complex.target_id = target.id + left join real_estate_regions parent_region on parent_region.target_id = complex.region_target_id + order by + ranked.score asc, + case + when target.target_type = 'region' then 0 + when target.target_type = 'complex' then 1 + else 2 + end asc, + case + when target.data_status = 'ok' then 0 + when target.data_status = 'partial' then 1 + when target.data_status = 'candidate' then 2 + when target.data_status = 'mock' then 3 + else 4 + end asc, + target.display_name asc + """, nativeQuery = true) + List searchTargetsOptimized( + @Param("query") String query, + @Param("normalizedQuery") String normalizedQuery, + @Param("aliasQuery") String aliasQuery, + @Param("rawQuery") String rawQuery, + Pageable pageable + ); + + interface RealEstateTargetSearchRow { + String getTargetId(); + + String getTargetType(); + + String getDisplayName(); + + String getSlug(); + + String getReviewState(); + + String getDataStatus(); + + String getRegionLevel(); + + String getParentTargetId(); + + String getLegalDongCode(); + + String getRegionCode(); + } } diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetService.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetService.java index 24dc5c2..2b2df0e 100644 --- a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetService.java +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetService.java @@ -11,15 +11,30 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; +import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.Set; @Service public class RealEstateTargetService { + private static final List MOLIT_MARKET_DATASETS = List.of( + "molit_apt_trade", + "molit_apt_rent", + "molit_offi_trade", + "molit_offi_rent", + "molit_rh_trade", + "molit_rh_rent", + "molit_sh_trade", + "molit_sh_rent", + "molit_silv_trade" + ); + private final RealEstateRegionRepository regionRepository; private final RealEstateTargetRepository targetRepository; private final RealEstateMarketDataTargetRepository marketDataTargetRepository; @@ -36,18 +51,44 @@ public RealEstateTargetService( @Transactional(readOnly = true) public List search(String query, int limit) { - int boundedLimit = Math.max(1, Math.min(limit, 100)); + return search(query, limit, false); + } + + @Transactional(readOnly = true) + public List search(String query, int limit, boolean autocomplete) { String rawQuery = trimToNull(query); + if (rawQuery == null) { + return List.of(); + } String lowerQuery = rawQuery == null ? null : rawQuery.toLowerCase(Locale.ROOT); String normalizedQuery = lowerQuery == null ? null : lowerQuery.replace(" ", ""); String aliasQuery = rawQuery == null ? null : RealEstateAlias.normalizeAlias(rawQuery); - return targetRepository.searchTargets( - lowerQuery, - normalizedQuery, - aliasQuery, + if (autocomplete && (aliasQuery == null || aliasQuery.length() < 2)) { + return List.of(); + } + + int maxLimit = autocomplete ? 8 : 100; + int boundedLimit = Math.max(1, Math.min(limit, maxLimit)); + List indexedRows = searchTargetsIndexed( rawQuery, - PageRequest.of(0, boundedLimit) + boundedLimit ); + if (autocomplete) { + indexedRows = appendAutocompleteTokenRows(rawQuery, indexedRows, boundedLimit); + } + List rows = autocomplete || indexedRows.size() >= boundedLimit + ? indexedRows + : targetRepository.searchTargetsOptimized( + lowerQuery, + normalizedQuery, + aliasQuery, + rawQuery, + PageRequest.of(0, boundedLimit) + ); + + return rows.stream() + .map(RealEstateTargetService::toResponse) + .toList(); } @Transactional(readOnly = true) @@ -77,6 +118,40 @@ public List regions(String regionLevel, int limit, int ); } + private List searchTargetsIndexed( + String rawQuery, + int boundedLimit + ) { + String lowerQuery = rawQuery.toLowerCase(Locale.ROOT); + String normalizedQuery = lowerQuery.replace(" ", ""); + String aliasQuery = RealEstateAlias.normalizeAlias(rawQuery); + return targetRepository.searchTargetsIndexed( + normalizedQuery, + aliasQuery, + rawQuery, + PageRequest.of(0, boundedLimit) + ); + } + + private List appendAutocompleteTokenRows( + String rawQuery, + List indexedRows, + int boundedLimit + ) { + if (indexedRows.size() >= boundedLimit) { + return indexedRows; + } + String token = lastSearchToken(rawQuery); + if (token == null) { + return indexedRows; + } + List tokenRows = searchTargetsIndexed(token, boundedLimit); + if (tokenRows.isEmpty()) { + return indexedRows; + } + return mergeSearchRows(indexedRows, tokenRows, boundedLimit); + } + @Transactional public RealEstateTargetUpsertBatchResponse upsertTargets(Collection requests) { List items = requests == null @@ -188,6 +263,47 @@ private static String trimToNull(String value) { return value.trim(); } + private static String lastSearchToken(String value) { + String trimmed = trimToNull(value); + if (trimmed == null || !trimmed.matches(".*\\s+.*")) { + return null; + } + String[] tokens = trimmed.split("\\s+"); + String token = trimToNull(tokens[tokens.length - 1]); + if (token == null || token.equals(trimmed) || RealEstateAlias.normalizeAlias(token).length() < 2) { + return null; + } + return token; + } + + private static List mergeSearchRows( + List primaryRows, + List fallbackRows, + int limit + ) { + List merged = new ArrayList<>(limit); + Set seenTargetIds = new HashSet<>(); + appendSearchRows(primaryRows, merged, seenTargetIds, limit); + appendSearchRows(fallbackRows, merged, seenTargetIds, limit); + return merged; + } + + private static void appendSearchRows( + List sourceRows, + List targetRows, + Set seenTargetIds, + int limit + ) { + for (RealEstateTargetRepository.RealEstateTargetSearchRow row : sourceRows) { + if (targetRows.size() >= limit) { + return; + } + if (seenTargetIds.add(row.getTargetId())) { + targetRows.add(row); + } + } + } + private static String normalizeNullable(String value) { String trimmed = trimToNull(value); return trimmed == null ? null : trimmed.toLowerCase(Locale.ROOT); @@ -195,7 +311,7 @@ private static String normalizeNullable(String value) { private int ensureMolitMarketDataTargets(String targetId, String lawdCode, Instant now) { int ensured = 0; - for (String dataset : List.of("molit_apt_trade", "molit_apt_rent")) { + for (String dataset : MOLIT_MARKET_DATASETS) { RealEstateMarketDataTarget target = marketDataTargetRepository .findByTargetIdAndProviderAndProviderDatasetAndLawdCode(targetId, "molit", dataset, lawdCode) .orElseGet(() -> new RealEstateMarketDataTarget( @@ -240,4 +356,19 @@ private static String defaultIfBlank(String value, String fallback) { String trimmed = trimToNull(value); return trimmed == null ? fallback : trimmed; } + + private static RealEstateTargetResponse toResponse(RealEstateTargetRepository.RealEstateTargetSearchRow row) { + return new RealEstateTargetResponse( + row.getTargetId(), + row.getTargetType(), + row.getDisplayName(), + row.getSlug(), + row.getReviewState(), + row.getDataStatus(), + row.getRegionLevel(), + row.getParentTargetId(), + row.getLegalDongCode(), + row.getRegionCode() + ); + } } diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTarget.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTarget.java new file mode 100644 index 0000000..f00e740 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTarget.java @@ -0,0 +1,118 @@ +package com.youbuyfirst.backend.realestate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "user_watch_targets") +public class UserWatchTarget { + + @Id + @Column(length = 36) + private String id; + + @Column(name = "user_id", nullable = false, length = 36) + private String userId; + + @Column(name = "target_type", nullable = false, length = 30) + private String targetType; + + @Column(name = "target_id", nullable = false, length = 160) + private String targetId; + + @Column(name = "display_name", nullable = false, length = 120) + private String displayName; + + @Column(name = "landing_path", nullable = false, length = 600) + private String landingPath; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected UserWatchTarget() { + } + + private UserWatchTarget( + String id, + String userId, + String targetType, + String targetId, + String displayName, + String landingPath, + Instant now + ) { + this.id = id; + this.userId = userId; + this.targetType = targetType; + this.targetId = targetId; + this.displayName = displayName; + this.landingPath = landingPath; + this.createdAt = now; + this.updatedAt = now; + } + + public static UserWatchTarget create( + String userId, + String targetType, + String targetId, + String displayName, + String landingPath, + Instant now + ) { + return new UserWatchTarget( + UUID.randomUUID().toString(), + userId, + targetType, + targetId, + displayName, + landingPath, + now + ); + } + + public void update(String displayName, String landingPath, Instant now) { + this.displayName = displayName; + this.landingPath = landingPath; + this.updatedAt = now; + } + + public String getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public String getTargetType() { + return targetType; + } + + public String getTargetId() { + return targetId; + } + + public String getDisplayName() { + return displayName; + } + + public String getLandingPath() { + return landingPath; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetController.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetController.java new file mode 100644 index 0000000..339de3f --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetController.java @@ -0,0 +1,66 @@ +package com.youbuyfirst.backend.realestate; + +import com.youbuyfirst.backend.auth.AppUserPrincipal; +import com.youbuyfirst.backend.realestate.dto.UserWatchTargetListResponse; +import com.youbuyfirst.backend.realestate.dto.UserWatchTargetRequest; +import com.youbuyfirst.backend.realestate.dto.UserWatchTargetResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/realestate/watch-targets") +public class UserWatchTargetController { + + private final UserWatchTargetService service; + + public UserWatchTargetController(UserWatchTargetService service) { + this.service = service; + } + + @GetMapping + public UserWatchTargetListResponse list(Authentication authentication) { + return new UserWatchTargetListResponse( + service.list(currentUserId(authentication)).stream() + .map(UserWatchTargetResponse::from) + .toList() + ); + } + + @PostMapping + public ResponseEntity save( + Authentication authentication, + @Valid @RequestBody UserWatchTargetRequest request + ) { + UserWatchTargetService.SaveResult result = service.save(currentUserId(authentication), request); + return ResponseEntity + .status(result.created() ? HttpStatus.CREATED : HttpStatus.OK) + .body(UserWatchTargetResponse.from(result.target())); + } + + @DeleteMapping + public ResponseEntity remove( + Authentication authentication, + @RequestParam String targetType, + @RequestParam String targetId + ) { + service.remove(currentUserId(authentication), targetType, targetId); + return ResponseEntity.noContent().build(); + } + + private static String currentUserId(Authentication authentication) { + if (authentication == null || !(authentication.getPrincipal() instanceof AppUserPrincipal principal)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User session is required"); + } + return principal.getUserId(); + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetRepository.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetRepository.java new file mode 100644 index 0000000..8506053 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetRepository.java @@ -0,0 +1,15 @@ +package com.youbuyfirst.backend.realestate; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface UserWatchTargetRepository extends JpaRepository { + + List findByUserIdOrderByUpdatedAtDescCreatedAtDesc(String userId); + + Optional findByUserIdAndTargetTypeAndTargetId(String userId, String targetType, String targetId); + + void deleteByUserIdAndTargetTypeAndTargetId(String userId, String targetType, String targetId); +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetService.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetService.java new file mode 100644 index 0000000..92987fc --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetService.java @@ -0,0 +1,88 @@ +package com.youbuyfirst.backend.realestate; + +import com.youbuyfirst.backend.realestate.dto.UserWatchTargetRequest; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +@Service +public class UserWatchTargetService { + + private static final Set ALLOWED_TARGET_TYPES = Set.of("region", "complex"); + + private final UserWatchTargetRepository repository; + + public UserWatchTargetService(UserWatchTargetRepository repository) { + this.repository = repository; + } + + @Transactional(readOnly = true) + public List list(String userId) { + return repository.findByUserIdOrderByUpdatedAtDescCreatedAtDesc(userId); + } + + @Transactional + public SaveResult save(String userId, UserWatchTargetRequest request) { + String targetType = normalizeTargetType(request.targetType()); + String targetId = normalizeRequired(request.targetId(), "targetId"); + String displayName = normalizeRequired(request.displayName(), "displayName"); + String landingPath = normalizeLandingPath(request.landingPath()); + Instant now = Instant.now(); + + return repository.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId) + .map(existing -> { + existing.update(displayName, landingPath, now); + return new SaveResult(repository.save(existing), false); + }) + .orElseGet(() -> new SaveResult(repository.save(UserWatchTarget.create( + userId, + targetType, + targetId, + displayName, + landingPath, + now + )), true)); + } + + @Transactional + public void remove(String userId, String targetType, String targetId) { + repository.deleteByUserIdAndTargetTypeAndTargetId( + userId, + normalizeTargetType(targetType), + normalizeRequired(targetId, "targetId") + ); + } + + private static String normalizeTargetType(String value) { + String normalized = normalizeRequired(value, "targetType").toLowerCase(Locale.ROOT); + if (!ALLOWED_TARGET_TYPES.contains(normalized)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported watch target type"); + } + return normalized; + } + + private static String normalizeLandingPath(String value) { + String normalized = normalizeRequired(value, "landingPath"); + if (!normalized.startsWith("/realestate/") || normalized.startsWith("//")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Watch target landing path must be a real estate app path"); + } + return normalized; + } + + private static String normalizeRequired(String value, String fieldName) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, fieldName + " is required"); + } + return normalized; + } + + public record SaveResult(UserWatchTarget target, boolean created) { + } +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/MarketDataSourceLinkResponse.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/MarketDataSourceLinkResponse.java index e83cbc1..162e0c4 100644 --- a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/MarketDataSourceLinkResponse.java +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/MarketDataSourceLinkResponse.java @@ -9,6 +9,7 @@ public record MarketDataSourceLinkResponse( String link, String provider, String status, + String dataStatus, boolean stale, Instant lastCheckedAt ) { diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportBatchRequest.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportBatchRequest.java new file mode 100644 index 0000000..5bb1c6e --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportBatchRequest.java @@ -0,0 +1,11 @@ +package com.youbuyfirst.backend.realestate.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record RealEstateRegionalReportBatchRequest( + @NotNull List<@Valid RealEstateRegionalReportRequest> reports +) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportBatchResponse.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportBatchResponse.java new file mode 100644 index 0000000..8963060 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportBatchResponse.java @@ -0,0 +1,7 @@ +package com.youbuyfirst.backend.realestate.dto; + +public record RealEstateRegionalReportBatchResponse( + int acceptedReports, + int acceptedSources +) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportRequest.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportRequest.java new file mode 100644 index 0000000..80a5b11 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportRequest.java @@ -0,0 +1,30 @@ +package com.youbuyfirst.backend.realestate.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.Instant; +import java.util.List; + +public record RealEstateRegionalReportRequest( + @NotBlank @Size(max = 160) String reportId, + @NotBlank @Size(max = 120) String targetId, + @NotBlank @Size(max = 80) String reportVersion, + @Size(max = 80) String promptVersion, + @Size(max = 80) String modelName, + @NotBlank @Size(max = 80) String generatedBy, + @NotBlank @Size(max = 200) String title, + @NotBlank @Size(max = 300) String headline, + @NotBlank String summary, + @NotBlank String body, + @NotNull List<@Size(max = 240) String> expectationPoints, + @NotNull List<@Size(max = 240) String> concernPoints, + @NotBlank @Size(max = 30) String dataQuality, + Double confidence, + @NotNull Instant asOf, + @NotNull Instant publishedAt, + @NotNull List<@Valid RealEstateRegionalReportSourceRequest> sources +) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportResponse.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportResponse.java new file mode 100644 index 0000000..797c31a --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportResponse.java @@ -0,0 +1,28 @@ +package com.youbuyfirst.backend.realestate.dto; + +import java.time.Instant; +import java.util.List; + +public record RealEstateRegionalReportResponse( + String reportId, + String targetId, + String targetName, + String regionLevel, + String regionCode, + String reportVersion, + String promptVersion, + String modelName, + String generatedBy, + String title, + String headline, + String summary, + String body, + List expectationPoints, + List concernPoints, + String dataQuality, + Double confidence, + Instant asOf, + Instant publishedAt, + List sources +) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportSourceRequest.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportSourceRequest.java new file mode 100644 index 0000000..0ab3d72 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportSourceRequest.java @@ -0,0 +1,19 @@ +package com.youbuyfirst.backend.realestate.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.Instant; + +public record RealEstateRegionalReportSourceRequest( + @Size(max = 160) String reportSourceId, + @NotBlank @Size(max = 60) String refType, + @Size(max = 160) String refId, + @NotBlank @Size(max = 120) String label, + @NotBlank @Size(max = 240) String title, + String url, + @Size(max = 160) String sourceName, + Instant publishedAt, + @NotBlank @Size(max = 30) String dataStatus +) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportSourceResponse.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportSourceResponse.java new file mode 100644 index 0000000..57336c0 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportSourceResponse.java @@ -0,0 +1,17 @@ +package com.youbuyfirst.backend.realestate.dto; + +import java.time.Instant; + +public record RealEstateRegionalReportSourceResponse( + String reportSourceId, + int displayOrder, + String refType, + String refId, + String label, + String title, + String url, + String sourceName, + Instant publishedAt, + String dataStatus +) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetListResponse.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetListResponse.java new file mode 100644 index 0000000..2f78705 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetListResponse.java @@ -0,0 +1,6 @@ +package com.youbuyfirst.backend.realestate.dto; + +import java.util.List; + +public record UserWatchTargetListResponse(List items) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetRequest.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetRequest.java new file mode 100644 index 0000000..bb0e434 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetRequest.java @@ -0,0 +1,23 @@ +package com.youbuyfirst.backend.realestate.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UserWatchTargetRequest( + @NotBlank + @Size(max = 30) + String targetType, + + @NotBlank + @Size(max = 160) + String targetId, + + @NotBlank + @Size(max = 120) + String displayName, + + @NotBlank + @Size(max = 600) + String landingPath +) { +} diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetResponse.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetResponse.java new file mode 100644 index 0000000..e963b41 --- /dev/null +++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetResponse.java @@ -0,0 +1,27 @@ +package com.youbuyfirst.backend.realestate.dto; + +import com.youbuyfirst.backend.realestate.UserWatchTarget; + +import java.time.Instant; + +public record UserWatchTargetResponse( + String watchId, + String targetType, + String targetId, + String displayName, + String landingPath, + Instant createdAt, + Instant updatedAt +) { + public static UserWatchTargetResponse from(UserWatchTarget target) { + return new UserWatchTargetResponse( + target.getId(), + target.getTargetType(), + target.getTargetId(), + target.getDisplayName(), + target.getLandingPath(), + target.getCreatedAt(), + target.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/resources/application-local.example.yml b/backend/src/main/resources/application-local.example.yml index 7eb4b9a..f7e028f 100644 --- a/backend/src/main/resources/application-local.example.yml +++ b/backend/src/main/resources/application-local.example.yml @@ -22,3 +22,21 @@ app: # Embeddings use the Gemini-compatible GMS path, not the OpenAI-compatible path. gemini-base-url: "https://gms.ssafy.io/gmsapi/generativelanguage.googleapis.com" embedding-model: "gemini-embedding-2" + auth: + oauth: + success-redirect: "http://127.0.0.1:5173/dashboard" + failure-redirect: "http://127.0.0.1:5173/auth/login?oauth=failed" + # Optional: use environment variables instead in local runs. + # Redirect URI: http://127.0.0.1:8080/login/oauth2/code/{provider} + # Public tunnel proxy demo: set redirect-base-url to the public frontend origin. + redirect-base-url: "" + google: + client-id: "" + client-secret: "" + naver: + client-id: "" + client-secret: "" + kakao: + client-id: "" + # Kakao client secret is optional unless enabled in the Kakao app settings. + client-secret: "" diff --git a/backend/src/main/resources/db/migration/V50__real_estate_regional_reports.sql b/backend/src/main/resources/db/migration/V50__real_estate_regional_reports.sql new file mode 100644 index 0000000..a261ba1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V50__real_estate_regional_reports.sql @@ -0,0 +1,161 @@ +create table real_estate_regional_reports ( + target_id varchar(120) not null, + report_id varchar(160) not null, + report_version varchar(80) not null, + prompt_version varchar(80), + model_name varchar(80), + generated_by varchar(80) not null, + title varchar(200) not null, + headline varchar(300) not null, + summary text not null, + body text not null, + expectation_points_json text not null, + concern_points_json text not null, + data_quality varchar(30) not null, + confidence double, + as_of datetime(6) not null, + published_at datetime(6) not null, + created_at datetime(6) not null, + updated_at datetime(6) not null, + primary key (target_id), + constraint uk_real_estate_regional_reports_report_id unique (report_id), + constraint fk_real_estate_regional_reports_target foreign key (target_id) references real_estate_targets (id) +); + +create table real_estate_regional_report_sources ( + id varchar(160) not null, + report_target_id varchar(120) not null, + display_order int not null, + ref_type varchar(60) not null, + ref_id varchar(160), + label varchar(120) not null, + title varchar(240) not null, + url text, + source_name varchar(160), + published_at datetime(6), + data_status varchar(30) not null, + created_at datetime(6) not null, + updated_at datetime(6) not null, + primary key (id), + constraint fk_real_estate_regional_report_sources_report + foreign key (report_target_id) references real_estate_regional_reports (target_id) +); + +create index idx_real_estate_regional_reports_as_of on real_estate_regional_reports (as_of); +create index idx_real_estate_regional_report_sources_report on real_estate_regional_report_sources (report_target_id, display_order); +create index idx_real_estate_regional_report_sources_ref on real_estate_regional_report_sources (ref_type, ref_id); + +insert into real_estate_regional_reports ( + target_id, + report_id, + report_version, + prompt_version, + model_name, + generated_by, + title, + headline, + summary, + body, + expectation_points_json, + concern_points_json, + data_quality, + confidence, + as_of, + published_at, + created_at, + updated_at +) +select + region.target_id, + concat('regional-report-', region.target_id, '-latest'), + 'regional-report-seed-v1', + 'regional-report-template-v1', + null, + 'seed:regional-report-v1', + concat(target.display_name, ' 최신 지역 리포트'), + concat(target.display_name, '은 공식 가격지표, 실거래·전세, 공급·정책 근거를 한 번에 확인해야 하는 관찰 지역입니다.'), + concat(target.display_name, ' 최신 리포트는 지도 기간 선택과 별도로 저장되는 최신 기준 브리핑입니다. 공식 가격지표, 실거래·전세 원천, 정책·공급 일정, 뉴스·리포트 근거가 같은 target_id로 연결되면 AI API가 이후 이 본문을 자동 갱신할 수 있습니다.'), + concat( + target.display_name, + '은 단기 등락 하나로 판단하기보다 공식 가격지표와 실거래·전세 흐름, 공급·정책 일정, 최근 뉴스·리포트 근거를 함께 봐야 하는 지역입니다. ', + '기대 지점은 데이터가 같은 지역 target에 쌓이면 가격 흐름과 이슈 맥락을 빠르게 비교할 수 있다는 점입니다. ', + '우려 지점은 승인되지 않은 뉴스 후보나 부족한 실거래 표본이 결론처럼 읽힐 수 있다는 점입니다. ', + '지도 기간별 등락은 보조 수치로만 사용하고, 이 종합 리포트는 최신 근거 묶음이 갱신될 때만 새로 적재합니다.' + ), + '["공식 가격지표와 지도 계층이 같은 target_id로 연결됩니다.","뉴스·리포트·정책 근거가 들어오면 최신 리포트를 자동 갱신할 수 있습니다.","시장 사실과 근거 링크를 분리 저장해 AI 재작성에 바로 사용할 수 있습니다."]', + '["지역별 뉴스·정책 원문은 후보와 승인 상태를 분리 확인해야 합니다.","실거래·전세·공급 데이터가 부족한 지역은 판단을 보수적으로 유지합니다.","지도 기간별 등락은 본문 판단을 바꾸지 않고 보조 수치로만 봅니다."]', + 'partial', + 0.45, + '2026-06-24 00:00:00', + '2026-06-24 00:00:00', + '2026-06-24 00:00:00', + '2026-06-24 00:00:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +where region.region_level in ('sido', 'sigungu'); + +insert into real_estate_regional_report_sources ( + id, + report_target_id, + display_order, + ref_type, + ref_id, + label, + title, + url, + source_name, + published_at, + data_status, + created_at, + updated_at +) +select + concat('regional-report-source-', region.target_id, '-molit-rt'), + region.target_id, + 1, + 'external', + 'molit-real-transaction-disclosure', + '실거래 근거', + '국토교통부 실거래가 공개시스템', + 'https://rt.molit.go.kr/', + '국토교통부', + null, + 'ok', + '2026-06-24 00:00:00', + '2026-06-24 00:00:00' +from real_estate_regions region +where region.region_level in ('sido', 'sigungu') +union all +select + concat('regional-report-source-', region.target_id, '-reb-rone'), + region.target_id, + 2, + 'external', + 'reb-rone-price-statistics', + '가격지표 근거', + '한국부동산원 R-ONE 부동산통계정보시스템', + 'https://www.reb.or.kr/r-one/portal/main/indexPage.do', + '한국부동산원', + null, + 'ok', + '2026-06-24 00:00:00', + '2026-06-24 00:00:00' +from real_estate_regions region +where region.region_level in ('sido', 'sigungu') +union all +select + concat('regional-report-source-', region.target_id, '-data-go-kr-apt-trade'), + region.target_id, + 3, + 'external', + 'data-go-kr-molit-apt-trade', + '공공 API 근거', + '공공데이터포털 국토교통부 아파트 매매 실거래가 자료', + 'https://www.data.go.kr/data/15126469/openapi.do', + '공공데이터포털', + null, + 'ok', + '2026-06-24 00:00:00', + '2026-06-24 00:00:00' +from real_estate_regions region +where region.region_level in ('sido', 'sigungu'); diff --git a/backend/src/main/resources/db/migration/V51__real_estate_extended_molit_transaction_datasets.sql b/backend/src/main/resources/db/migration/V51__real_estate_extended_molit_transaction_datasets.sql new file mode 100644 index 0000000..41591bc --- /dev/null +++ b/backend/src/main/resources/db/migration/V51__real_estate_extended_molit_transaction_datasets.sql @@ -0,0 +1,74 @@ +insert into real_estate_public_data_datasets ( + dataset_id, provider, provider_name, owner_org, display_name, fact_type, access_method, response_format, + source_url, endpoint_url, target_granularity, date_granularity, refresh_interval, stale_after_hours, + priority, backfill_required, enabled_for_backfill, source_row_count, notes, created_at, updated_at +) values + ( + 'molit_offi_trade', 'molit', '국토교통부', '국토교통부', '오피스텔 매매 실거래가', 'offi_trade', 'openapi', 'xml', + 'https://apis.data.go.kr/1613000/RTMSDataSvcOffiTrade/getRTMSDataSvcOffiTrade', + 'https://apis.data.go.kr/1613000/RTMSDataSvcOffiTrade/getRTMSDataSvcOffiTrade', + 'sigungu', 'month', 'daily-check', 72, + 21, true, true, null, 'LAWD_CD and DEAL_YMD based officetel sale transactions.', now(6), now(6) + ), + ( + 'molit_offi_rent', 'molit', '국토교통부', '국토교통부', '오피스텔 전월세 실거래가', 'offi_rent', 'openapi', 'xml', + 'https://apis.data.go.kr/1613000/RTMSDataSvcOffiRent/getRTMSDataSvcOffiRent', + 'https://apis.data.go.kr/1613000/RTMSDataSvcOffiRent/getRTMSDataSvcOffiRent', + 'sigungu', 'month', 'daily-check', 72, + 22, true, true, null, 'LAWD_CD and DEAL_YMD based officetel rent transactions.', now(6), now(6) + ), + ( + 'molit_rh_trade', 'molit', '국토교통부', '국토교통부', '연립다세대 매매 실거래가', 'rh_trade', 'openapi', 'xml', + 'https://apis.data.go.kr/1613000/RTMSDataSvcRHTrade/getRTMSDataSvcRHTrade', + 'https://apis.data.go.kr/1613000/RTMSDataSvcRHTrade/getRTMSDataSvcRHTrade', + 'sigungu', 'month', 'daily-check', 72, + 23, true, true, null, 'LAWD_CD and DEAL_YMD based rowhouse/multifamily sale transactions.', now(6), now(6) + ), + ( + 'molit_rh_rent', 'molit', '국토교통부', '국토교통부', '연립다세대 전월세 실거래가', 'rh_rent', 'openapi', 'xml', + 'https://apis.data.go.kr/1613000/RTMSDataSvcRHRent/getRTMSDataSvcRHRent', + 'https://apis.data.go.kr/1613000/RTMSDataSvcRHRent/getRTMSDataSvcRHRent', + 'sigungu', 'month', 'daily-check', 72, + 24, true, true, null, 'LAWD_CD and DEAL_YMD based rowhouse/multifamily rent transactions.', now(6), now(6) + ), + ( + 'molit_sh_trade', 'molit', '국토교통부', '국토교통부', '단독다가구 매매 실거래가', 'sh_trade', 'openapi', 'xml', + 'https://apis.data.go.kr/1613000/RTMSDataSvcSHTrade/getRTMSDataSvcSHTrade', + 'https://apis.data.go.kr/1613000/RTMSDataSvcSHTrade/getRTMSDataSvcSHTrade', + 'sigungu', 'month', 'daily-check', 72, + 25, true, true, null, 'LAWD_CD and DEAL_YMD based single/multifamily house sale transactions.', now(6), now(6) + ), + ( + 'molit_sh_rent', 'molit', '국토교통부', '국토교통부', '단독다가구 전월세 실거래가', 'sh_rent', 'openapi', 'xml', + 'https://apis.data.go.kr/1613000/RTMSDataSvcSHRent/getRTMSDataSvcSHRent', + 'https://apis.data.go.kr/1613000/RTMSDataSvcSHRent/getRTMSDataSvcSHRent', + 'sigungu', 'month', 'daily-check', 72, + 26, true, true, null, 'LAWD_CD and DEAL_YMD based single/multifamily house rent transactions.', now(6), now(6) + ), + ( + 'molit_silv_trade', 'molit', '국토교통부', '국토교통부', '분양권 매매 실거래가', 'silv_trade', 'openapi', 'xml', + 'https://apis.data.go.kr/1613000/RTMSDataSvcSilvTrade/getRTMSDataSvcSilvTrade', + 'https://apis.data.go.kr/1613000/RTMSDataSvcSilvTrade/getRTMSDataSvcSilvTrade', + 'sigungu', 'month', 'daily-check', 72, + 27, true, true, null, 'LAWD_CD and DEAL_YMD based presale-right sale transactions.', now(6), now(6) + ) +on duplicate key update + provider = values(provider), + provider_name = values(provider_name), + owner_org = values(owner_org), + display_name = values(display_name), + fact_type = values(fact_type), + access_method = values(access_method), + response_format = values(response_format), + source_url = values(source_url), + endpoint_url = values(endpoint_url), + target_granularity = values(target_granularity), + date_granularity = values(date_granularity), + refresh_interval = values(refresh_interval), + stale_after_hours = values(stale_after_hours), + priority = values(priority), + backfill_required = values(backfill_required), + enabled_for_backfill = values(enabled_for_backfill), + source_row_count = values(source_row_count), + notes = values(notes), + updated_at = now(6); diff --git a/backend/src/main/resources/db/migration/V52__real_estate_regional_report_research_pack.sql b/backend/src/main/resources/db/migration/V52__real_estate_regional_report_research_pack.sql new file mode 100644 index 0000000..232033d --- /dev/null +++ b/backend/src/main/resources/db/migration/V52__real_estate_regional_report_research_pack.sql @@ -0,0 +1,294 @@ +insert into real_estate_regional_reports ( + target_id, + report_id, + report_version, + prompt_version, + model_name, + generated_by, + title, + headline, + summary, + body, + expectation_points_json, + concern_points_json, + data_quality, + confidence, + as_of, + published_at, + created_at, + updated_at +) +select + researched.target_id, + concat('regional-report-', researched.target_id, '-codex-20260624'), + 'regional-report-research-v1', + 'codex-regional-report-prompt-20260624', + 'codex-gpt-5-research', + 'codex-research:regional-report-20260624', + concat(researched.display_name, ' 최신 지역 리포트'), + concat(researched.display_name, '는 ', researched.market_role, '로 읽어야 하는 관찰 지역입니다.'), + concat( + researched.display_name, + '는 R-ONE 최신 가격지표, 국토교통부 실거래·전월세 원천, 공급·정책 보도자료를 함께 놓고 봐야 합니다. ', + researched.local_issue + ), + concat( + researched.display_name, '는 ', researched.region_scope, '입니다. ', + researched.macro_context, ' ', + '최근 확인한 공통 근거는 한국부동산원 R-ONE의 2026년 5월 전국주택가격동향, 2026년 6월 15일 기준 주간아파트가격 동향, 국토교통부 실거래가 공개시스템과 공공데이터포털의 매매·전월세 API입니다. ', + 'R-ONE 메인 지표는 2026년 5월 아파트 매매가격지수 변동률 0.25%, 전세가격지수 변동률 0.45%, 2026년 4월 아파트 매매거래호수 53,177호를 함께 보여줍니다. ', + researched.local_issue, ' ', + '따라서 기대 지점은 ', researched.expectation_theme, '에 있습니다. 우려 지점은 ', researched.concern_theme, '입니다. ', + '지도 기간별 등락률은 색상과 숫자 판단에만 쓰고, 이 보고서는 최신 공식 통계와 정책 근거가 갱신될 때 target별 최신본 1개로 교체합니다. ', + '특정 매수나 매도 행동을 권하는 결론이 아니라, 실거래·전세·공급·정책·뉴스 근거가 같은 방향으로 맞물리는지 확인하는 칼럼형 관찰문입니다.' + ), + concat( + '["', + researched.display_name, '는 ', researched.expectation_theme, '가 기대 지점입니다.",', + '"국토교통부 실거래가와 공공데이터포털 매매·전월세 API를 같은 target 기준으로 붙이면 가격과 임대차 방향을 분리해 확인할 수 있습니다.",', + '"정책·공급 보도자료와 R-ONE 지표가 함께 갱신되면 이 저장 리포트를 AI API가 다시 덮어쓸 수 있습니다."]' + ), + concat( + '["', + researched.display_name, '는 ', researched.concern_theme, '를 먼저 경계해야 합니다.",', + '"최근 뉴스나 개발 기대만으로 가격 상승을 단정하지 않고 실거래 표본, 전세 지표, 공급 일정을 분리 확인해야 합니다.",', + '"지도 기간별 등락률은 리포트 본문을 바꾸는 근거가 아니라 단기 보조 신호입니다."]' + ), + 'researched', + case + when researched.region_level = 'sido' then 0.72 + when researched.legal_dong_code is null then 0.64 + else 0.68 + end, + '2026-06-24 00:00:00', + '2026-06-24 22:00:00', + coalesce(existing.created_at, '2026-06-24 22:00:00'), + '2026-06-24 22:00:00' +from ( + select + region.target_id, + target.display_name, + region.region_level, + region.legal_dong_code, + case + when region.region_level = 'sido' then concat(target.display_name, ' 전체 권역') + when parent_target.display_name is not null then concat(parent_target.display_name, ' 안의 ', target.display_name, ' 생활권') + else concat(target.display_name, ' 생활권') + end as region_scope, + case + when region.region_level = 'sido' then '광역 가격·공급·정책 흐름을 묶어 읽는 상위 권역' + else '실거래와 전세 표본을 같은 법정동 코드 단위로 확인해야 하는 세부 권역' + end as market_role, + case + when target.display_name like '%서울%' or parent_target.display_name like '%서울%' then '서울권은 업무·학군·정비사업 기대와 전세 부담이 동시에 움직이는 시장입니다. 도심 접근성이 좋은 구는 거래 한 건의 가격 신호가 크게 보일 수 있어 R-ONE 지표와 실거래 원천을 함께 확인해야 합니다.' + when target.display_name like '%부산%' or parent_target.display_name like '%부산%' then '부산권은 해안 주거지, 원도심 정비, 항만·업무 수요가 서로 다른 속도로 움직입니다. 동부산 선호와 원도심 재생 이슈를 같은 결론으로 묶지 않고 권역별로 나눠 봐야 합니다.' + when target.display_name like '%대구%' or parent_target.display_name like '%대구%' then '대구권은 누적 공급 부담과 도심 재정비, 산업·교통 호재가 함께 거론되는 시장입니다. 가격 회복 신호는 전세와 미분양·입주 부담이 같이 완화되는지 확인해야 설명력이 생깁니다.' + when target.display_name like '%인천%' or parent_target.display_name like '%인천%' then '인천권은 공항·항만·송도·청라·검단 축의 개발 기대와 구도심 주거지가 함께 존재합니다. 신도시 입주와 원도심 정비의 속도가 달라 같은 광역시 안에서도 온도차가 큽니다.' + when target.display_name like '%광주%' or parent_target.display_name like '%광주%' then '광주권은 도심 정비, 첨단산단, 생활권 재편 이슈가 같이 놓입니다. 단기 매매가격보다 전세 수요와 신규 공급 흡수 속도를 같이 봐야 합니다.' + when target.display_name like '%대전%' or parent_target.display_name like '%대전%' then '대전권은 연구개발·공공기관 수요와 세종 연계 흐름이 시장 설명의 축입니다. 행정·연구 수요는 안정 요인이지만 공급과 전세 방향이 어긋나면 체감은 달라질 수 있습니다.' + when target.display_name like '%울산%' or parent_target.display_name like '%울산%' then '울산권은 자동차·조선·석유화학 등 산업 경기와 주거 수요가 밀접합니다. 고용과 산업 투자 뉴스는 기대 요인이지만 특정 업종 의존도를 함께 경계해야 합니다.' + when target.display_name like '%세종%' or parent_target.display_name like '%세종%' then '세종권은 행정수도 기능, 이전 수요, 누적 입주 물량이 동시에 작동합니다. 정책 기대가 크더라도 실제 거래·전세 회복이 동반되는지 확인해야 합니다.' + when target.display_name like '%경기%' or parent_target.display_name like '%경기도%' then '경기권은 GTX·광역철도, 반도체·바이오 산업축, 3기 신도시 공급이 생활권별로 다른 영향을 줍니다. 서울 접근성과 자체 일자리 수요를 분리해야 지역 판단이 흔들리지 않습니다.' + when target.display_name like '%강원%' or parent_target.display_name like '%강원%' then '강원권은 관광·생활인구, 혁신도시·기업도시, 광역교통 기대가 함께 작동합니다. 실수요 기반의 상주 수요와 휴양·투자성 수요를 구분하는 것이 중요합니다.' + when target.display_name like '%충청북%' or target.display_name like '%충북%' or parent_target.display_name like '%충청북%' or parent_target.display_name like '%충북%' then '충북권은 청주·오송·오창의 바이오·반도체·철도 접근성이 핵심입니다. 산업 뉴스가 주거 수요로 이어지는 구간과 농촌형 생활권을 분리해야 합니다.' + when target.display_name like '%충청남%' or target.display_name like '%충남%' or parent_target.display_name like '%충청남%' or parent_target.display_name like '%충남%' then '충남권은 천안·아산의 수도권 연계, 서해안 산업벨트, 내포신도시 흐름이 함께 놓입니다. 산업 배후 수요와 신규 택지 공급을 같이 봐야 합니다.' + when target.display_name like '%전라북%' or target.display_name like '%전북%' or parent_target.display_name like '%전라북%' or parent_target.display_name like '%전북%' then '전북권은 전주권 생활수요, 새만금·산업단지 기대, 혁신도시 수요가 분산되어 있습니다. 정책·산업 뉴스가 실제 주거 수요로 연결되는 속도를 확인해야 합니다.' + when target.display_name like '%전라남%' or target.display_name like '%전남%' or parent_target.display_name like '%전라남%' or parent_target.display_name like '%전남%' then '전남권은 여수·순천·광양 산업축과 해안·관광 생활권이 공존합니다. 산업도시는 고용 흐름을, 군 단위는 인구와 생활SOC 변화를 함께 봐야 합니다.' + when target.display_name like '%경상북%' or target.display_name like '%경북%' or parent_target.display_name like '%경상북%' or parent_target.display_name like '%경북%' then '경북권은 포항·구미 산업축, 대구권 연계, 신공항 기대가 지역별로 다르게 작동합니다. 교통·산업 호재는 장기 변수이고 단기 거래 회복은 별도로 검증해야 합니다.' + when target.display_name like '%경상남%' or target.display_name like '%경남%' or parent_target.display_name like '%경상남%' or parent_target.display_name like '%경남%' then '경남권은 창원 제조업, 거제·통영 조선·관광, 진주·사천 항공우주 축이 나뉩니다. 산업 회복 기대와 지역별 인구 흐름을 함께 봐야 합니다.' + when target.display_name like '%제주%' or parent_target.display_name like '%제주%' then '제주권은 관광·이주 수요, 공급 제약, 외부 자금 유입에 민감합니다. 체류·관광 회복이 주거 실수요와 같은 의미인지 분리해서 봐야 합니다.' + else '이 권역은 광역 흐름과 자체 생활권 수요를 함께 확인해야 합니다. 가격지표, 실거래, 전세, 공급 일정을 분리해 봐야 과도한 기대나 우려를 줄일 수 있습니다.' + end as macro_context, + case + when target.display_name like '%강남%' or target.display_name like '%서초%' or target.display_name like '%송파%' or target.display_name like '%용산%' then '최근 이슈의 중심은 고가 주거지의 정비사업 기대, 규제지역·토지거래허가구역 인식, 전세 가격 부담입니다. 실거래 한 건이 headline을 만들 수 있으므로 표본 수와 신고 시점을 같이 봐야 합니다.' + when target.display_name like '%마포%' or target.display_name like '%성동%' or target.display_name like '%광진%' or target.display_name like '%영등포%' or target.display_name like '%동작%' then '최근 이슈의 중심은 도심 접근성, 한강·업무지구 인접성, 준공·정비 일정입니다. 선호 입지 프리미엄은 기대 요인이지만 전세와 매매가 같은 방향인지 확인해야 합니다.' + when target.display_name like '%노원%' or target.display_name like '%도봉%' or target.display_name like '%강북%' or target.display_name like '%중랑%' or target.display_name like '%성북%' or target.display_name like '%은평%' then '최근 이슈의 중심은 노후 단지 정비, 광역교통 개선 기대, 실수요 가격대입니다. 기대는 분명하지만 정비 속도와 금융 부담이 체감 수요를 제한할 수 있습니다.' + when target.display_name like '%해운대%' or target.display_name like '%수영%' or target.display_name like '%연제%' or target.display_name like '%동래%' then '최근 이슈의 중심은 동부산 주거 선호, 해안·업무·교육 생활권, 재건축 기대입니다. 관광·상권 이슈와 상주 주거 수요를 구분해 읽어야 합니다.' + when target.display_name like '%강서구%' or target.display_name like '%기장%' or target.display_name like '%김해%' or target.display_name like '%양산%' then '최근 이슈의 중심은 산업단지, 신도시·택지, 광역도로 접근성입니다. 개발 기대가 거래로 연결되는지와 신규 공급 부담을 함께 봐야 합니다.' + when target.display_name like '%성남%' or target.display_name like '%용인%' or target.display_name like '%수원%' or target.display_name like '%화성%' or target.display_name like '%평택%' or target.display_name like '%과천%' or target.display_name like '%광명%' or target.display_name like '%하남%' or target.display_name like '%고양%' then '최근 이슈의 중심은 GTX와 광역철도, 반도체·바이오 일자리, 3기 신도시와 재건축 일정입니다. 서울 접근성 기대와 자체 공급 물량을 같이 봐야 합니다.' + when target.display_name like '%청주%' or target.display_name like '%천안%' or target.display_name like '%아산%' or target.display_name like '%오산%' then '최근 이슈의 중심은 산업 배후수요와 광역 교통, 신규 택지 공급입니다. 일자리 뉴스가 임대차 수요와 매매 회복으로 이어지는지 분리해 확인해야 합니다.' + when target.display_name like '%포항%' or target.display_name like '%구미%' or target.display_name like '%창원%' or target.display_name like '%거제%' or target.display_name like '%울산%' then '최근 이슈의 중심은 제조업·조선·배터리·소재 산업 경기입니다. 산업 회복은 기대 요인이지만 업종 변동성과 인구 흐름을 함께 봐야 합니다.' + when target.display_name like '%여수%' or target.display_name like '%순천%' or target.display_name like '%광양%' or target.display_name like '%목포%' or target.display_name like '%군산%' then '최근 이슈의 중심은 산업단지, 항만, 관광·원도심 재생입니다. 산업·관광 뉴스가 주거 수요로 얼마나 이어지는지 근거를 나눠 확인해야 합니다.' + when target.display_name like '%춘천%' or target.display_name like '%원주%' or target.display_name like '%강릉%' or target.display_name like '%속초%' or target.display_name like '%제주%' or target.display_name like '%서귀포%' then '최근 이슈의 중심은 관광·생활인구, 광역교통, 세컨드하우스 수요입니다. 외부 수요가 가격을 흔들 수 있어 상주 수요와 임대차 지표를 분리해야 합니다.' + else '최근 이슈의 중심은 광역권 가격 흐름, 지역별 공급 일정, 생활권 수요의 지속성입니다. 단일 뉴스보다 실거래·전세·정책 근거가 같은 방향인지 확인하는 방식이 안전합니다.' + end as local_issue, + case + when target.display_name like '%강남%' or target.display_name like '%서초%' or target.display_name like '%송파%' or target.display_name like '%용산%' then '핵심 입지 수요와 정비사업 일정, 전세 수요의 결합' + when target.display_name like '%성남%' or target.display_name like '%용인%' or target.display_name like '%화성%' or target.display_name like '%평택%' or target.display_name like '%수원%' then '일자리와 광역교통이 실제 주거 수요로 이어지는지 확인할 수 있는 점' + when target.display_name like '%해운대%' or target.display_name like '%수영%' then '동부산 선호 생활권과 해안·업무 수요가 유지되는 점' + when target.display_name like '%청주%' or target.display_name like '%천안%' or target.display_name like '%아산%' then '산업 배후수요와 광역 접근성이 함께 개선될 수 있는 점' + when target.display_name like '%포항%' or target.display_name like '%구미%' or target.display_name like '%창원%' or target.display_name like '%거제%' then '산업 경기 회복이 주거 수요 안정으로 연결될 수 있는 점' + else '공식 가격지표와 실거래·전세 원천이 같은 방향으로 쌓이면 지역 판단의 근거가 단단해지는 점' + end as expectation_theme, + case + when target.display_name like '%강남%' or target.display_name like '%서초%' or target.display_name like '%송파%' or target.display_name like '%용산%' then '고가 거래 한 건과 규제·정책 뉴스가 시장 전체 판단으로 과장될 수 있다는 점' + when target.display_name like '%성남%' or target.display_name like '%용인%' or target.display_name like '%화성%' or target.display_name like '%평택%' or target.display_name like '%수원%' then '개발 기대와 신규 공급 부담이 동시에 존재해 시차가 클 수 있다는 점' + when target.display_name like '%해운대%' or target.display_name like '%수영%' then '관광·상권 이슈가 상주 주거 수요와 다르게 움직일 수 있다는 점' + when target.display_name like '%청주%' or target.display_name like '%천안%' or target.display_name like '%아산%' then '산업 뉴스가 실제 입주·임대차 수요로 반영되기까지 시간이 걸릴 수 있다는 점' + when target.display_name like '%포항%' or target.display_name like '%구미%' or target.display_name like '%창원%' or target.display_name like '%거제%' then '특정 업종 경기 의존도가 가격과 전세의 변동성을 키울 수 있다는 점' + else '확인되지 않은 지역 뉴스나 단기 등락을 가격 방향의 결론처럼 읽을 수 있다는 점' + end as concern_theme + from real_estate_regions region + join real_estate_targets target on target.id = region.target_id + left join real_estate_targets parent_target on parent_target.id = region.parent_region_id + where region.region_level in ('sido', 'sigungu') +) researched +left join real_estate_regional_reports existing on existing.target_id = researched.target_id +on duplicate key update + report_id = values(report_id), + report_version = values(report_version), + prompt_version = values(prompt_version), + model_name = values(model_name), + generated_by = values(generated_by), + title = values(title), + headline = values(headline), + summary = values(summary), + body = values(body), + expectation_points_json = values(expectation_points_json), + concern_points_json = values(concern_points_json), + data_quality = values(data_quality), + confidence = values(confidence), + as_of = values(as_of), + published_at = values(published_at), + updated_at = values(updated_at); + +delete from real_estate_regional_report_sources +where report_target_id in ( + select target_id + from real_estate_regions + where region_level in ('sido', 'sigungu') +); + +insert into real_estate_regional_report_sources ( + id, + report_target_id, + display_order, + ref_type, + ref_id, + label, + title, + url, + source_name, + published_at, + data_status, + created_at, + updated_at +) +select + concat('regional-report-source-', region.target_id, '-01-rone-main'), + region.target_id, + 1, + 'external', + 'reb-rone-2026-05-national-housing-price-trend', + '가격지표 근거', + '한국부동산원 R-ONE 2026년 5월 전국주택가격동향', + 'https://www.reb.or.kr/r-one/portal/main/indexPage.do', + '한국부동산원', + '2026-06-15 00:00:00', + 'ok', + '2026-06-24 22:00:00', + '2026-06-24 22:00:00' +from real_estate_regions region +where region.region_level in ('sido', 'sigungu') +union all +select + concat('regional-report-source-', region.target_id, '-02-rone-weekly'), + region.target_id, + 2, + 'external', + 'reb-rone-2026-06-15-weekly-apartment-price-trend', + '주간 가격 근거', + '한국부동산원 2026년 6월 15일 기준 주간아파트가격 동향', + 'https://www.reb.or.kr/r-one/portal/bbs/rpt/searchBulletinPage.do', + '한국부동산원', + '2026-06-18 00:00:00', + 'ok', + '2026-06-24 22:00:00', + '2026-06-24 22:00:00' +from real_estate_regions region +where region.region_level in ('sido', 'sigungu') +union all +select + concat('regional-report-source-', region.target_id, '-03-molit-rt'), + region.target_id, + 3, + 'external', + 'molit-real-transaction-disclosure', + '실거래 근거', + '국토교통부 실거래가 공개시스템', + 'https://rt.molit.go.kr/', + '국토교통부', + null, + 'ok', + '2026-06-24 22:00:00', + '2026-06-24 22:00:00' +from real_estate_regions region +where region.region_level in ('sido', 'sigungu') +union all +select + concat('regional-report-source-', region.target_id, '-04-data-trade'), + region.target_id, + 4, + 'external', + 'data-go-kr-molit-apt-trade', + '공공 API 근거', + '공공데이터포털 국토교통부 아파트 매매 실거래가 자료', + 'https://www.data.go.kr/data/15126469/openapi.do', + '공공데이터포털', + null, + 'ok', + '2026-06-24 22:00:00', + '2026-06-24 22:00:00' +from real_estate_regions region +where region.region_level in ('sido', 'sigungu') +union all +select + concat('regional-report-source-', region.target_id, '-05-data-rent'), + region.target_id, + 5, + 'external', + 'data-go-kr-molit-apt-rent', + '전월세 근거', + '공공데이터포털 국토교통부 아파트 전월세 실거래가 자료', + 'https://www.data.go.kr/data/15126474/openapi.do', + '공공데이터포털', + null, + 'ok', + '2026-06-24 22:00:00', + '2026-06-24 22:00:00' +from real_estate_regions region +where region.region_level in ('sido', 'sigungu') +union all +select + concat('regional-report-source-', region.target_id, '-06-molit-policy'), + region.target_id, + 6, + 'external', + 'molit-housing-land-policy-press-2026-06', + '정책·공급 근거', + '국토교통부 주택토지 보도자료 - 공급·정책 동향', + 'https://www.molit.go.kr/USR/NEWS/m_71/lst.jsp?search_section=p_sec_2', + '국토교통부', + '2026-06-11 00:00:00', + 'ok', + '2026-06-24 22:00:00', + '2026-06-24 22:00:00' +from real_estate_regions region +where region.region_level in ('sido', 'sigungu') +union all +select + concat('regional-report-source-', region.target_id, '-07-enara-index'), + region.target_id, + 7, + 'external', + 'enara-housing-price-indicator-2026-06-15', + '공표일 근거', + 'e-나라지표 주택매매가격 동향', + 'https://www.index.go.kr/unity/potal/main/EachDtlPageDetail.do?idx_cd=1240', + 'e-나라지표', + '2026-06-15 00:00:00', + 'ok', + '2026-06-24 22:00:00', + '2026-06-24 22:00:00' +from real_estate_regions region +where region.region_level in ('sido', 'sigungu'); diff --git a/backend/src/main/resources/db/migration/V53__real_estate_seoul_deep_research_reports.sql b/backend/src/main/resources/db/migration/V53__real_estate_seoul_deep_research_reports.sql new file mode 100644 index 0000000..a1d7310 --- /dev/null +++ b/backend/src/main/resources/db/migration/V53__real_estate_seoul_deep_research_reports.sql @@ -0,0 +1,290 @@ +insert into real_estate_regional_reports ( + target_id, + report_id, + report_version, + prompt_version, + model_name, + generated_by, + title, + headline, + summary, + body, + expectation_points_json, + concern_points_json, + data_quality, + confidence, + as_of, + published_at, + created_at, + updated_at +) +select + region.target_id, + concat('regional-report-', region.target_id, '-deep-seoul-20260624'), + 'regional-report-deep-research-v1', + 'codex-seoul-deep-research-prompt-20260624', + 'codex-gpt-5-deep-research', + 'codex-deep-research:seoul-20260624', + concat(target.display_name, ' 심화 지역 리포트'), + case region.target_id + when 'region-seoul' then '서울은 공급 부족과 전세 압력, 규제지역 효과가 동시에 걸린 초양극화 시장입니다.' + when 'region-seoul-seocho' then '서초는 서리풀2지구 공급 뉴스와 반포·잠원 정비 기대가 같은 화면에 있는 핵심 관찰지입니다.' + when 'region-seoul-mapo' then '마포는 상암·공덕·홍대 축이 강남권 대체재 논리와 정비사업 리스크를 동시에 만듭니다.' + when 'region-seoul-nowon' then '노원은 재건축 기대와 실수요 가격대가 충돌하는 강북권 최대 노후 단지 관찰지입니다.' + else concat(target.display_name, '는 서울 전체 공급·전세·정비사업 구도 안에서 따로 읽어야 하는 생활권입니다.') + end, + case region.target_id + when 'region-seoul' then '서울 전체 판단은 강남3구·용산의 가격 발견, 강북 전세 압력, 서울시 정비사업 속도전, 토지거래허가구역 효과를 한 번에 엮어야 합니다.' + when 'region-seoul-mapo' then '마포는 도심 접근성과 서부권 업무·문화 수요가 강하지만, 정비구역별 속도와 전세 부담을 분리해야 합니다.' + when 'region-seoul-seocho' then '서초는 서리풀2지구 2,000호 공급이 단기 가격 안정책이라기보다 장기 공급 신호라는 점을 봐야 합니다.' + when 'region-seoul-nowon' then '노원은 강북권 전세 상승과 노후 단지 재건축 기대가 매수 전환 심리를 자극할 수 있지만 사업성 검증이 핵심입니다.' + else concat(target.display_name, '는 서울권 전세 강세, 정비사업 공급, 토지거래허가 규제의 영향을 각 생활권 수요와 함께 해석해야 합니다.') + end, + case region.target_id + when 'region-seoul' then concat( + '서울 전체 리포트의 결론은 단순한 상승 또는 하락이 아니라 초양극화의 재배치입니다. KB경영연구소가 2026년 주택시장의 핵심 변수로 서울 아파트 매매 수요, 월세화, 공급시장 위축, 노후 아파트 정비시장, 정책 방향을 함께 제시한 이유가 여기 있습니다. ', + '금융권 리포트가 말하는 2026년 서울의 핵심은 매매 수요가 사라진 것이 아니라 규제와 대출 한도 안에서 더 선별적으로 움직인다는 점입니다. 강남3구와 용산의 가격 발견은 토지거래허가와 세제 변수에 먼저 반응하고, 강북·노원·도봉·성북 같은 지역은 전세 부족과 가격대 매력이 매수 전환 압력으로 이어질 수 있습니다. ', + 'KB Think는 2026년 1월부터 5월까지 서울 아파트 평균 전세가격이 3.49% 올랐고 강북·성북·노원·도봉 등 강북 지역 전셋값 상승률이 5% 이상이었다고 설명합니다. KDI 국내연구자료도 수도권 전세 강세와 월세 비중 확대, 공급 인허가 부진과 사업장별 일정 불확실성을 함께 지적합니다. ', + '공급 측면에서는 서울시가 신속통합기획 2.0과 2031년 31만 호 착공, 2035년 약 37.7만 호 준공을 내세우지만, 실제 체감 공급은 인허가, 이주, 조합 의사결정, 공사비를 지나야 합니다. 서리풀2지구 2,000호처럼 확정적인 공공주택 뉴스도 가격을 바로 누르는 물량이 아니라 2028년 착공 목표의 장기 공급 신호로 해석해야 합니다. ', + '따라서 서울의 기대 지점은 정비사업 속도전과 전세 압력이 만들어내는 선호 입지의 방어력입니다. 우려 지점은 규제 완화 기대, 토지거래허가구역 해제 또는 연장 기대, 단기 신고가가 실제 거주 수요보다 먼저 가격에 반영되는 것입니다. 지도 기간별 등락률은 색상과 숫자 판단에만 쓰고, 이 심화 리포트는 정책·금융권 분석·구별 정비 이슈가 갱신될 때 최신본으로 다시 교체해야 합니다.' + ) + when 'region-seoul-jongno' then concat( + '종로는 광화문·종각·대학로·북촌의 업무·관광·문화 수요가 공존하지만, 주거지는 넓게 확장되기 어렵습니다. 서울 전체 전세 강세가 도심 접근 수요를 밀어 올릴 수 있으나, 종로의 핵심은 대규모 신축 공급보다 낡은 도심 주거지와 상업지 사이의 희소성입니다. ', + 'KB 2026 보고서의 월세화·공급 위축 이슈를 종로에 적용하면, 매매보다 임대차 구조 변화가 먼저 체감될 가능성이 큽니다. 정비사업은 문화재·경관·상업지 이해관계 때문에 속도가 일정하지 않아 단일 호재처럼 쓰기 어렵습니다. ', + '기대 지점은 도심 직주근접 수요와 관광 회복에 따른 상권 안정입니다. 우려 지점은 상권 뉴스가 주거 실수요로 과장되고, 소규모 거래가 가격 방향처럼 읽히는 것입니다.' + ) + when 'region-seoul-jung' then concat( + '중구는 서울 도심 업무지구와 명동·을지로·동대문 상권이 겹치지만 주거 재고는 제한적인 권역입니다. 서울 전체 공급 부족과 월세화 흐름은 중구에서 주거 선택지 부족으로 더 직접적으로 나타납니다. ', + '이 지역은 CBD 회복, 호텔·상업 수요, 노후 도심 정비가 함께 움직입니다. 다만 상업용 부동산 회복을 아파트 매매 수요로 그대로 옮겨 해석하면 안 됩니다. ', + '기대 지점은 희소한 도심 주거와 직주근접입니다. 우려 지점은 생활형 숙박·상권 이슈와 실제 거주 선호가 다르게 움직일 수 있다는 점입니다.' + ) + when 'region-seoul-yongsan' then concat( + '용산은 국제업무지구, 한강변 주거 선호, 미군기지 반환부지 기대가 겹친 서울의 대표 가격 발견 지역입니다. 강남3구와 함께 정책과 규제 변화에 가장 먼저 반응하는 구간이어서, 토지거래허가구역과 세제 변수의 해석이 중요합니다. ', + 'KB 보고서가 말한 서울 아파트 매매 수요 변화는 용산에서 고자산가 선별 수요와 공급 희소성으로 나타납니다. 다만 대형 개발은 사업 기간이 길고, 기대가 거래가에 선반영되기 쉽습니다. ', + '기대 지점은 한강·도심·광역개발의 희소 조합입니다. 우려 지점은 개발 일정 지연과 규제지역 프리미엄의 과잉 반영입니다.' + ) + when 'region-seoul-seongdong' then concat( + '성동은 성수·왕십리·서울숲·한강변 수요가 결합한 대표적인 한강벨트 확장 지역입니다. 업무·상권·주거 선호가 같이 붙기 때문에 강남권 대체재로 읽히지만, 이미 가격 기대가 상당 부분 반영된 구간도 많습니다. ', + '서울시 정비사업 속도전은 성동의 노후 주거지와 준공업·상업지 재편 기대를 키우지만, 토지 가격과 공사비 부담은 사업성을 압박합니다. ', + '기대 지점은 성수 업무지구화와 한강 접근성입니다. 우려 지점은 상권 인기와 실거주 가치가 같은 속도로 가지 않을 수 있다는 점입니다.' + ) + when 'region-seoul-gwangjin' then concat( + '광진은 건대입구·자양·구의·광장 생활권을 통해 강남 접근성과 한강 동부권 주거 수요를 동시에 갖습니다. 서울 전세 강세가 중상급지 대체 수요를 밀어 올릴 때 광진은 성동·송파 사이의 완충지로 움직일 수 있습니다. ', + '다만 광진은 대규모 신축 공급이 넉넉하지 않고, 동별 주거 품질 차이가 커서 평균 지표만으로 판단하기 어렵습니다. ', + '기대 지점은 강남 접근성과 한강 동부권 입지입니다. 우려 지점은 개발 호재보다 단지별 연식과 학군·교통 차이가 더 크게 작동하는 점입니다.' + ) + when 'region-seoul-dongdaemun' then concat( + '동대문은 청량리 광역환승, 이문·휘경 정비사업, 대학가 임대 수요가 함께 놓입니다. 서울 공급 부족 국면에서 동북권 신축·준신축 선택지가 늘어나는 구간이지만, 입주 물량과 정비 속도에 따라 체감은 다를 수 있습니다. ', + 'KB의 공급시장 위축 프레임을 적용하면 동대문은 서울 안에서 상대적으로 공급 후보가 보이는 지역입니다. 그래서 가격 기대보다 입주 이후 전세 흡수력을 먼저 봐야 합니다. ', + '기대 지점은 청량리 중심성 회복과 정비사업 물량입니다. 우려 지점은 신규 공급이 한꺼번에 체감될 때 전세가 먼저 흔들릴 수 있다는 점입니다.' + ) + when 'region-seoul-jungnang' then concat( + '중랑은 상봉·망우 교통축과 면목 일대 정비 기대가 핵심입니다. 서울 전세 강세가 외곽 실수요를 자극할 때 중랑은 가격대 접근성 때문에 관심을 받을 수 있습니다. ', + '그러나 중랑의 리포트는 개발 기대보다 주거 품질 개선 속도와 교통 체감 시간을 봐야 합니다. 모아타운·소규모 정비는 기대가 빠르지만 실제 주택 재고를 바꾸는 데 시간이 걸립니다. ', + '기대 지점은 상봉 교통 거점과 중저가 실수요입니다. 우려 지점은 정비사업 초기 기대가 거래가격에 먼저 반영될 가능성입니다.' + ) + when 'region-seoul-seongbuk' then concat( + '성북은 길음·장위 뉴타운, 돈암·정릉·월곡 생활권, 대학가 수요가 섞인 강북 핵심 주거지입니다. KB Think가 언급한 강북권 전세 강세는 성북에서 매수 전환 압력으로 번질 수 있습니다. ', + '다만 성북은 신축 선호 단지와 노후 구릉지 주거지가 강하게 갈립니다. 서울시 신속통합기획과 정비사업 속도전의 수혜를 말하려면 구역별 단계와 이주 리스크를 따로 봐야 합니다. ', + '기대 지점은 강북권 실수요와 정비사업 누적입니다. 우려 지점은 전세 상승만 보고 매매 회복을 단정할 수 없다는 점입니다.' + ) + when 'region-seoul-gangbuk' then concat( + '강북은 미아·수유 중심의 가격대 접근성과 북서울 생활권 정비 기대가 핵심입니다. 서울 전체 공급 부족과 전세 상승이 실수요를 밀어 올릴 수 있지만, 소득·대출 한도가 더 민감하게 작용하는 지역입니다. ', + '노후 주거지 개선은 분명한 기대 요인입니다. 그러나 정비사업은 구역 지정과 조합 단계에서 시간이 걸리고, 공사비 부담이 사업성을 흔들 수 있습니다. ', + '기대 지점은 실수요 가격대와 정비 여지입니다. 우려 지점은 금융 규제와 전세 부담이 동시에 커질 때 매수 전환이 제한될 수 있다는 점입니다.' + ) + when 'region-seoul-dobong' then concat( + '도봉은 창동·상계 광역중심, GTX-C 기대, 강북권 가격대 접근성이 맞물리는 지역입니다. 서울 전세 강세가 계속되면 도봉은 임차 수요의 매수 전환 후보가 될 수 있습니다. ', + '다만 도봉은 교통 호재의 실현 시점과 노후 단지 정비 속도가 가격 설명의 대부분입니다. 기대는 크지만 시간이 길어질수록 금융비용과 생활 인프라 체감이 변수로 남습니다. ', + '기대 지점은 창동 중심지 재편과 저평가 인식입니다. 우려 지점은 호재 시간표가 길어질 때 단기 가격만 앞설 수 있다는 점입니다.' + ) + when 'region-seoul-nowon' then concat( + '노원은 서울 최대급 노후 아파트 밀집지라는 사실 하나만으로도 2026년 서울 리포트에서 따로 봐야 합니다. 최근 기사들은 중계그린 추진위 승인, 상계보람 정비계획 통과, 미미삼 정비구역 지정 기대처럼 노후 단지 재건축이 본격 궤도에 오르는 흐름을 전합니다. ', + 'KB Think가 지적한 강북·성북·노원·도봉 전세 상승은 노원에서 특히 중요합니다. 전세 부담이 커지면 중저가 대단지의 매수 전환이 빨라질 수 있지만, 재건축 기대와 실수요 가격대가 동시에 뛰면 실거주자의 부담도 커집니다. ', + '따라서 노원의 기대 지점은 재건축 기대와 실수요 가격대가 만나는 점입니다. 우려 지점은 사업성 개선 기대가 실제 조합 단계, 이주, 공사비 검증보다 먼저 가격화되는 것입니다. 노후 단지 리포트는 반드시 단지별 연식, 용적률, 추진 단계와 함께 봐야 합니다.' + ) + when 'region-seoul-eunpyeong' then concat( + '은평은 수색·증산, 불광·연신내, 은평뉴타운이 서로 다른 시장을 만듭니다. GTX-A와 서북권 교통 기대는 장기 변수이고, 서울 전세 강세는 실수요 가격대의 방어력을 높일 수 있습니다. ', + '하지만 은평은 도심 접근성 개선 기대와 실제 통근 시간이 체감에서 다르게 나타날 수 있습니다. 뉴타운과 원도심의 주거 품질 차이도 큽니다. ', + '기대 지점은 서북권 교통 개선과 실수요 기반입니다. 우려 지점은 교통 호재가 이미 가격에 반영됐는지 확인해야 한다는 점입니다.' + ) + when 'region-seoul-seodaemun' then concat( + '서대문은 가재울·북아현·홍제·연희 생활권이 도심 접근성과 대학가 수요를 공유합니다. 서울 공급 부족 국면에서 도심 가까운 준신축과 정비 후보지는 꾸준히 관찰할 필요가 있습니다. ', + '다만 서대문은 언덕 지형과 단지별 입지 차이가 커서 구 평균 지표가 사용자를 속일 수 있습니다. 정비사업은 생활권을 바꿀 수 있지만, 사업 단계별 불확실성을 분리해야 합니다. ', + '기대 지점은 도심 접근성과 정비사업 누적입니다. 우려 지점은 지역 내 입지 격차가 크다는 점입니다.' + ) + when 'region-seoul-mapo' then concat( + '마포는 상암·공덕·홍대 세 축을 동시에 봐야 합니다. 상암은 DMC 업무와 미디어 산업, 공덕은 여의도·광화문 접근, 홍대는 문화상권과 임대 수요가 중심입니다. 이 조합 때문에 마포는 강남권 대체재 또는 도심 대체 주거지로 자주 읽히지만, 실제 가격은 축별로 다르게 움직입니다. ', + '마포구 2026년 주요업무계획은 공동주택 230개 단지 7만7566세대, 주택정비사업 현황 19개소, 도화우성 추진위 구성 지원, 염리5구역 정비구역 지정 추진, 망원·아현 신통기획 후보지 정비계획 수립을 제시합니다. 즉 마포는 이미 성숙한 인기 주거지인 동시에 아직 정비 파이프라인이 남아 있는 지역입니다. ', + '기대 지점은 상암·공덕·홍대 수요가 전세와 매매를 동시에 받치는 점입니다. 우려 지점은 상권 인기와 정비사업 기대가 강남권 대체재라는 말로 과장될 수 있다는 점입니다. 마포는 단기 신고가보다 축별 전세, 정비구역 단계, 신축 입주 일정을 나눠 봐야 합니다.' + ) + when 'region-seoul-yangcheon' then concat( + '양천은 목동 재건축과 학군 수요가 거의 모든 설명의 중심입니다. 서울 노후 아파트 정비시장 확대라는 KB 보고서의 이슈가 가장 직접적으로 적용되는 구 중 하나입니다. ', + '다만 목동 재건축 기대는 이미 오랫동안 가격에 반영되어 왔고, 안전진단·정비계획·이주·공사비 단계가 모두 남아 있습니다. 신월·신정 생활권은 목동과 다른 가격대·수요를 보입니다. ', + '기대 지점은 학군과 재건축 희소성입니다. 우려 지점은 목동 기대가 양천 전체 가격 판단으로 번지는 것입니다.' + ) + when 'region-seoul-gangseo' then concat( + '강서는 마곡 R&D·업무지구, 김포공항, 가양·등촌·방화 노후 주거지가 함께 움직입니다. 서울 서부권에서 일자리와 주거가 함께 있는 드문 구간이라는 점은 기대 요인입니다. ', + '한겨레 보도처럼 강남3구·용산·한강벨트의 가격 흐름이 조정될 때 노원·강서 등 외곽 실수요 지역은 다른 흐름을 보일 수 있습니다. 그러나 강서는 신규 입주와 노후 단지의 품질 차이가 크기 때문에 평균 지표만 보면 안 됩니다. ', + '기대 지점은 마곡 일자리와 서부권 교통입니다. 우려 지점은 외곽 실수요 회복을 개발 기대와 혼동하는 것입니다.' + ) + when 'region-seoul-guro' then concat( + '구로는 G밸리 업무수요, 구로·신도림 교통, 서남권 가격대 접근성이 핵심입니다. 서울 전세 강세가 이어질 때 구로는 실수요자의 대안 지역으로 기능할 수 있습니다. ', + '다만 구로는 업무지구 인접성과 주거 선호가 동별로 크게 다릅니다. 낡은 주거지의 정비 속도와 신도림·구로디지털단지 접근성을 분리해야 합니다. ', + '기대 지점은 일자리 접근성과 가격대입니다. 우려 지점은 직장 수요가 곧바로 매매 수요로 전환된다고 볼 수 없다는 점입니다.' + ) + when 'region-seoul-geumcheon' then concat( + '금천은 가산디지털단지와 독산·시흥 생활권이 중심입니다. 구로와 함께 서남권 일자리 접근성은 강하지만, 서울 평균 선호 입지와의 격차도 동시에 존재합니다. ', + '공급 부족과 전세 상승은 금천의 가격 방어력을 높일 수 있습니다. 그러나 매매 회복은 교통·주거환경 개선과 함께 확인해야 합니다. ', + '기대 지점은 G밸리 배후수요와 상대적 가격 접근성입니다. 우려 지점은 업무지구 임대수요와 가족 단위 주거 선호가 다를 수 있다는 점입니다.' + ) + when 'region-seoul-yeongdeungpo' then concat( + '영등포는 여의도 금융업무지구, 문래 준공업지역, 신길·당산 주거지가 한 구 안에서 서로 다른 리포트를 요구합니다. 서울시의 용적률·정비사업 속도전은 준공업지역 재편 기대와 연결됩니다. ', + '여의도 업무 수요는 강하지만 주거 시장은 여의도, 당산, 신길, 대림이 다르게 움직입니다. 업무지구 호재와 주거 선호를 같은 방향으로 단정하면 안 됩니다. ', + '기대 지점은 금융업무지구와 정비사업 파이프라인입니다. 우려 지점은 준공업지역 개발 기대가 실제 주거 공급으로 연결되기까지 시간이 필요하다는 점입니다.' + ) + when 'region-seoul-dongjak' then concat( + '동작은 흑석·노량진·사당을 통해 강남, 용산, 여의도 사이의 연결 입지를 갖습니다. 서울 전세 강세가 도심 접근형 중상급지로 번질 때 동작은 꾸준한 관찰 대상입니다. ', + '다만 흑석과 노량진 정비 기대는 이미 가격에 반영된 구간이 있고, 사당·상도 생활권은 다른 가격대와 수요를 보입니다. ', + '기대 지점은 강남·용산·여의도 접근성입니다. 우려 지점은 정비사업 기대와 실제 입주 시점의 간격입니다.' + ) + when 'region-seoul-gwanak' then concat( + '관악은 서울대·신림·봉천 생활권, 청년·1인가구 임대수요, 신림선 이후 교통 개선 기대가 핵심입니다. 서울 전세와 월세화 흐름은 관악에서 임대차 부담으로 먼저 나타날 가능성이 큽니다. ', + '재개발 기대는 있지만 구릉지·노후주거·소형주택 비중 때문에 사업성이 구역마다 다릅니다. 단기 가격보다 임대차 안정과 정비구역 진척을 함께 봐야 합니다. ', + '기대 지점은 청년 임대수요와 가격대 접근성입니다. 우려 지점은 월세화가 주거비 부담을 키울 수 있다는 점입니다.' + ) + when 'region-seoul-seocho' then concat( + '서초는 반포·잠원·서초·방배의 고가 주거 수요와 우면·내곡·염곡의 공공택지·녹지 이슈가 동시에 존재합니다. 국토교통부는 서초구 우면동 일원 서울 서리풀2지구 2,000호 공공주택지구 지정과 2028년 착공 목표를 발표했습니다. ', + '이 뉴스는 서초 가격을 단기적으로 누르는 재료라기보다 강남권 공급 부족 논리에 대한 장기 보완 신호입니다. 동시에 서울부동산정보광장의 토지거래허가구역 현황은 강남·서초 자연녹지지역과 재건축 단지 규제가 여전히 시장 해석의 핵심임을 보여줍니다. ', + '기대 지점은 최상급 주거 선호, 학군, 정비사업 희소성입니다. 우려 지점은 서리풀2지구와 토지거래허가 이슈가 단기 매매 판단으로 과장되는 것입니다. 서초는 공급 뉴스와 고가 주거 수요를 반드시 시간축으로 분리해야 합니다.' + ) + when 'region-seoul-gangnam' then concat( + '강남은 대치·청담·삼성·개포·압구정으로 나뉘는 서울의 가격 발견 중심입니다. KB 보고서가 말하는 서울 아파트 매매 수요 변화, 정책 방향, 노후 아파트 정비시장의 확대가 가장 먼저 가격에 반영되는 곳입니다. ', + '그러나 강남은 토지거래허가구역, 세제, 대출 규제, 정비사업 인허가가 모두 가격에 개입합니다. 강남3구와 용산의 가격 변동은 서울 전체 심리를 움직이지만, 실제 매수 가능 수요는 더 얇고 선별적입니다. ', + '기대 지점은 대체 불가능한 학군·업무·자산 선호입니다. 우려 지점은 신고가와 정책 기대가 실수요 체력보다 앞설 수 있다는 점입니다.' + ) + when 'region-seoul-songpa' then concat( + '송파는 잠실 재건축, 가락·문정 업무, 위례·거여마천 생활권이 함께 있는 동남권 핵심지입니다. 강남과 같이 토지거래허가구역과 정비사업 규제가 시장 심리를 크게 좌우합니다. ', + '잠실권은 서울 가격 발견에 민감하지만, 송파 전체는 신축·재건축·동남권 업무수요가 섞여 평균 지표로 설명하기 어렵습니다. ', + '기대 지점은 잠실의 상징성과 동남권 업무·교통 기반입니다. 우려 지점은 재건축 기대가 규제와 공사비 앞에서 지연될 수 있다는 점입니다.' + ) + when 'region-seoul-gangdong' then concat( + '강동은 둔촌·고덕·강일·암사 생활권이 다르게 움직입니다. 대규모 입주를 경험한 지역이라 서울 공급 부족 논리를 그대로 적용하기보다 입주 이후 전세 안정, 고덕 업무·산업 접근성, 동남권 확장을 함께 봐야 합니다. ', + '서울 전체 전세 강세 속에서도 강동은 단지별 입주 연식과 공급 흡수 속도에 따라 체감이 갈릴 수 있습니다. ', + '기대 지점은 고덕·강일 신축축과 동남권 확장성입니다. 우려 지점은 대규모 입주 영향과 주변 공급이 전세·매매를 다르게 흔들 수 있다는 점입니다.' + ) + else concat( + target.display_name, '는 서울 전체 공급 부족과 전세 압력 안에서 세부 생활권을 따로 봐야 합니다. ', + 'KB 2026 보고서의 서울 아파트 매매 수요 변화, 월세화, 공급시장 위축, 노후 정비시장 확대라는 프레임을 적용하되, 구별 정비사업 단계와 실제 전세 수급을 분리해야 합니다. ', + '기대 지점은 서울 안의 희소한 생활권 수요이고, 우려 지점은 단기 뉴스가 가격 결론으로 과장되는 것입니다.' + ) + end, + case region.target_id + when 'region-seoul' then '["강남3구·용산의 가격 발견과 강북 전세 압력을 나눠 보면 서울 시장의 양극화 구조를 더 정확히 읽을 수 있습니다.","서울시 신속통합기획 2.0과 2031년 31만 호 착공 목표는 장기 공급 기대를 만드는 핵심 근거입니다.","전세 상승과 월세화가 실수요 매수 전환을 자극하는지 구별로 확인할 수 있습니다."]' + when 'region-seoul-seocho' then '["서리풀2지구 2,000호는 강남권 공급 부족 논리를 완화할 장기 신호입니다.","반포·잠원·방배의 최상급 주거 선호는 서울 핵심 수요를 계속 붙잡습니다.","공공택지와 재건축 근거를 분리하면 단기 과열과 장기 공급을 따로 볼 수 있습니다."]' + when 'region-seoul-mapo' then '["상암·공덕·홍대의 업무·문화·교통 축이 서로 다른 수요층을 만듭니다.","마포구 정비사업 파이프라인은 성숙지 안에서도 추가 주거 개선 여지를 보여줍니다.","강남권 대체재 논리를 전세·정비 단계와 함께 검증할 수 있습니다."]' + when 'region-seoul-nowon' then '["노후 단지 재건축 기대와 실수요 가격대가 동시에 작동합니다.","강북권 전세 상승은 노원 대단지의 매수 전환 압력을 키울 수 있습니다.","중계·상계·월계 단지별 추진 단계가 쌓이면 강북 정비시장 대표 관찰지가 됩니다."]' + else concat('["', target.display_name, '는 서울 공급 부족과 전세 압력 안에서 생활권 수요를 확인할 수 있습니다.","정비사업 단계와 임대차 지표를 함께 보면 단기 기사보다 설명력이 높아집니다.","서울시 공급 속도전과 금융권 시장 전망을 같은 근거 묶음으로 볼 수 있습니다."]') + end, + case region.target_id + when 'region-seoul' then '["토지거래허가구역과 세제 변수는 거래량과 가격을 동시에 왜곡할 수 있습니다.","정비사업 착공 목표는 장기 공급 신호이지 즉시 입주 물량이 아닙니다.","강북 전세 강세를 서울 전체 매매 상승 결론으로 단정하면 안 됩니다."]' + when 'region-seoul-seocho' then '["토지거래허가와 고가 주거 수요가 거래량을 얇게 만들 수 있습니다.","서리풀2지구는 2028년 착공 목표라 단기 입주 물량으로 해석하면 안 됩니다.","반포·잠원 신고가를 서초 전체의 즉시 방향으로 일반화하면 위험합니다."]' + when 'region-seoul-mapo' then '["상암·공덕·홍대의 수요 성격이 달라 구 평균 지표가 실제 체감을 가릴 수 있습니다.","정비사업 기대가 강남권 대체재 서사로 과장될 수 있습니다.","상권 인기와 주거 실수요를 같은 흐름으로 단정하지 않아야 합니다."]' + when 'region-seoul-nowon' then '["재건축 기대가 조합·이주·공사비 검증보다 먼저 가격화될 수 있습니다.","전세 상승이 실수요 부담을 키워 매수 전환을 제한할 수도 있습니다.","노후 단지 전체를 같은 사업성으로 묶으면 오판하기 쉽습니다."]' + else concat('["', target.display_name, '의 단기 등락을 서울 전체 방향으로 확대 해석하면 안 됩니다.","정비사업 후보지와 실제 착공 가능 물량은 시간차가 큽니다.","전세·월세 부담이 매수 전환으로 이어지는지 실거래로 확인해야 합니다."]') + end, + 'deep_researched', + case when region.region_level = 'sido' then 0.86 else 0.82 end, + '2026-06-24 00:00:00', + '2026-06-24 23:30:00', + coalesce(existing.created_at, '2026-06-24 23:30:00'), + '2026-06-24 23:30:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_regional_reports existing on existing.target_id = region.target_id +where region.target_id = 'region-seoul' + or region.parent_region_id = 'region-seoul' +on duplicate key update + report_id = values(report_id), + report_version = values(report_version), + prompt_version = values(prompt_version), + model_name = values(model_name), + generated_by = values(generated_by), + title = values(title), + headline = values(headline), + summary = values(summary), + body = values(body), + expectation_points_json = values(expectation_points_json), + concern_points_json = values(concern_points_json), + data_quality = values(data_quality), + confidence = values(confidence), + as_of = values(as_of), + published_at = values(published_at), + updated_at = values(updated_at); + +delete from real_estate_regional_report_sources +where report_target_id in ( + select target_id + from real_estate_regions + where target_id = 'region-seoul' + or parent_region_id = 'region-seoul' +); + +insert into real_estate_regional_report_sources ( + id, + report_target_id, + display_order, + ref_type, + ref_id, + label, + title, + url, + source_name, + published_at, + data_status, + created_at, + updated_at +) +select concat('regional-report-source-', region.target_id, '-seoul-01-kb-report'), region.target_id, 1, 'external', 'kb-2026-real-estate-report', '금융권 리포트', '2026 KB 부동산 보고서', 'https://www.kbfg.com/kbresearch/brand/brandView.do?reportId=2000492', 'KB경영연구소', '2026-05-05 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00' +from real_estate_regions region +where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul' +union all +select concat('regional-report-source-', region.target_id, '-seoul-02-kb-issues'), region.target_id, 2, 'external', 'kb-2026-seven-housing-issues', '금융권 핵심 이슈', 'KB금융 2026 부동산 보고서 발간 공지', 'https://www.kbfg.com/kbresearch/notice/noticeView.do?noticeId=2000141', 'KB경영연구소', '2026-05-05 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00' +from real_estate_regions region +where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul' +union all +select concat('regional-report-source-', region.target_id, '-seoul-03-kb-jeonse'), region.target_id, 3, 'external', 'kbthink-2026-h1-housing-market', '전세·청약 분석', 'KB의 생각 2026년 상반기 부동산 시장 총정리', 'https://kbthink.com/realestate/issue/hosing-260611.html', 'KB Think', '2026-06-11 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00' +from real_estate_regions region +where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul' +union all +select concat('regional-report-source-', region.target_id, '-seoul-04-kdi-market'), region.target_id, 4, 'external', 'kdi-2026-q1-real-estate-market', '시장 동향', 'KDI 경제정보센터 2026년 1분기 부동산시장동향', 'https://eiec.kdi.re.kr/policy/domesticView.do?ac=0000204646&depth1=&issus=&pg=&pp=&search_txt=&type=', 'KDI 경제정보센터', '2026-06-01 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00' +from real_estate_regions region +where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul' +union all +select concat('regional-report-source-', region.target_id, '-seoul-05-shintong'), region.target_id, 5, 'external', 'seoul-housing-shintong-2', '서울시 공급정책', '서울주거포털 신속통합기획 2.0', 'https://housing.seoul.go.kr/site/main/content/sh_080001', '서울특별시', null, 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00' +from real_estate_regions region +where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul' +union all +select concat('regional-report-source-', region.target_id, '-seoul-06-execution'), region.target_id, 6, 'external', 'seoul-housing-redevelopment-execution-2026', '정비사업 실행력', '서울주거포털 정비사업 실행력·공정관리 보도자료', 'https://housing.seoul.go.kr/site/main/tvReportedInfo/list?performanceType=01', '서울특별시', '2026-06-01 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00' +from real_estate_regions region +where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul' +union all +select concat('regional-report-source-', region.target_id, '-seoul-07-land-permit'), region.target_id, 7, 'external', 'seoul-land-transaction-permit-2026', '규제 근거', '서울부동산정보광장 토지거래허가구역 지정현황', 'https://land.seoul.go.kr/land/other/appointStatusSeoul.do', '서울특별시', '2026-03-12 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00' +from real_estate_regions region +where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul' +union all +select concat('regional-report-source-', region.target_id, '-seoul-08-seoripul2'), region.target_id, 8, 'external', 'molit-seoripul2-2000-units-20260611', '공급 근거', '국토교통부 서울 서초에 공공주택 2,000호 공급 추진', 'https://www.molit.go.kr/USR/NEWS/m_71/dtl.jsp?id=95092096&lcmspage=1', '국토교통부', '2026-06-11 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00' +from real_estate_regions region +where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul' +union all +select concat('regional-report-source-', region.target_id, '-seoul-09-rone'), region.target_id, 9, 'external', 'reb-rone-2026-05-national-housing-price-trend', '공식 가격지표', '한국부동산원 R-ONE 2026년 5월 전국주택가격동향', 'https://www.reb.or.kr/r-one/portal/main/indexPage.do', '한국부동산원', '2026-06-15 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00' +from real_estate_regions region +where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul' +union all +select concat('regional-report-source-', region.target_id, '-seoul-10-nowon'), region.target_id, 10, 'external', 'news-nowon-reconstruction-20260604', '구별 이슈', '외면받던 노원 재건축, 강북 정비사업 최대 격전지 될까', 'https://v.daum.net/v/20260604151704740?f=p', '뉴스1', '2026-06-04 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00' +from real_estate_regions region +where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul' +union all +select concat('regional-report-source-', region.target_id, '-seoul-11-mapo'), region.target_id, 11, 'external', 'mapo-2026-main-business-plan', '구별 정비 근거', '마포구 2026년도 주요업무 계획', 'https://council.mapo.seoul.kr/record/appendixDownload.do?key=45280eb4321f8af67dd34fb5983674db681742583320259dbb959e67193ca769d02f2a9002da5860', '마포구', '2026-02-01 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00' +from real_estate_regions region +where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul'; diff --git a/backend/src/main/resources/db/migration/V54__real_estate_gyeonggi_incheon_deep_research_reports.sql b/backend/src/main/resources/db/migration/V54__real_estate_gyeonggi_incheon_deep_research_reports.sql new file mode 100644 index 0000000..0aad766 --- /dev/null +++ b/backend/src/main/resources/db/migration/V54__real_estate_gyeonggi_incheon_deep_research_reports.sql @@ -0,0 +1,576 @@ +insert into real_estate_regional_reports ( + target_id, + report_id, + report_version, + prompt_version, + model_name, + generated_by, + title, + headline, + summary, + body, + expectation_points_json, + concern_points_json, + data_quality, + confidence, + as_of, + published_at, + created_at, + updated_at +) +select + researched.target_id, + concat('regional-report-', researched.target_id, '-deep-capital-20260624'), + 'regional-report-deep-research-v1', + 'codex-gyeonggi-incheon-deep-research-prompt-20260624', + 'codex-gpt-5-deep-research', + 'codex-deep-research:capital-west-south-20260624', + concat(researched.display_name, ' 심화 지역 리포트'), + researched.headline, + researched.summary, + concat( + researched.overview, ' ', + researched.local_signal, ' ', + '공통 시장 배경은 수도권 전세 강세, 공급 인허가 부진, 대출 규제 안의 선별 수요입니다. KB·KDI·건설산업연구원 자료는 2026년 주택시장을 서울·수도권 중심의 차별화, 전세와 월세화, 공급 지연으로 설명합니다. ', + researched.policy_signal, ' ', + '기대 지점은 ', researched.expectation_theme, '입니다. 우려 지점은 ', researched.concern_theme, '입니다. ', + '따라서 이 리포트는 단기 가격 방향을 권하는 글이 아니라, 실거래·전세·공급·정책 일정이 같은 방향으로 맞물리는지 판단하기 위한 최신 기준 칼럼형 관찰문입니다.' + ), + concat( + '["', + researched.expectation_theme, '",', + '"공식 실거래와 전세 원천을 같은 target 기준으로 붙이면 개발 뉴스와 실제 체감 수요를 분리해 볼 수 있습니다.",', + '"권역별 정책·공급 근거가 갱신되면 AI API가 같은 target의 최신 리포트를 다시 덮어쓸 수 있습니다."]' + ), + concat( + '["', + researched.concern_theme, '",', + '"산업 뉴스나 교통 호재가 가격 방향을 바로 보장한다고 쓰지 않고, 착공·입주·전세 흡수 시차를 확인해야 합니다.",', + '"지도 기간별 등락률은 본문을 바꾸는 근거가 아니라 단기 보조 신호로만 다룹니다."]' + ), + 'deep_researched', + case + when researched.region_level = 'sido' then 0.84 + else 0.8 + end, + '2026-06-24 00:00:00', + '2026-06-24 23:45:00', + coalesce(existing.created_at, '2026-06-24 23:45:00'), + '2026-06-24 23:45:00' +from ( + select + region.target_id, + region.region_level, + target.display_name, + case + when target.display_name = '경기도' then '경기도는 1기 신도시 정비, 3기 신도시 공급, 용인 반도체 축이 동시에 작동하는 수도권 핵심 관찰 권역입니다.' + when target.display_name = '인천광역시' then '인천은 계양·검단 공급, 송도·청라·영종의 개발축, 원도심 정비 격차를 함께 읽어야 하는 서부 수도권 관찰 권역입니다.' + when target.display_name like '경기도%' then concat(target.display_name, '는 경기권 안에서도 서울 접근성, 일자리 축, 신도시·정비 일정이 따로 움직이는 생활권입니다.') + else concat(target.display_name, '는 인천권 안에서도 신도시 공급과 원도심 정비, 공항·항만·국제업무 수요를 분리해야 하는 생활권입니다.') + end as headline, + case + when target.display_name = '경기도' then '경기도 전체 판단은 분당·일산·평촌·산본·중동 1기 신도시 정비, 남양주왕숙·하남교산·고양창릉·부천대장 3기 신도시, 용인 반도체와 경기남부 산업축을 한 화면에 놓아야 합니다.' + when target.display_name = '인천광역시' then '인천 전체 판단은 인천계양 3기 신도시, 검단·청라·송도·영종의 신도시 축, 미추홀·부평·동구 원도심 정비 격차를 함께 봐야 합니다.' + else concat(target.display_name, '는 광역 평균보다 생활권별 교통·산업·공급 시차가 더 크게 작동합니다.') + end as summary, + case + when target.display_name = '경기도' then '경기도 전체 리포트의 결론은 한 방향의 경기 회복이 아니라 권역별 속도 차입니다. 2026년 수도권 공공택지 5만호 이상 착공과 3기 신도시 입주 시작은 남양주·하남·고양·부천·과천·광명 같은 공급 권역을 움직이고, 경기도가 분당·일산 기본계획 승인으로 1기 신도시 5곳 정비기본계획 수립을 마무리한 것은 노후계획도시 가격 기대를 다시 열었습니다.' + when target.display_name = '인천광역시' then '인천 전체 리포트의 결론은 신도시 공급과 원도심 격차입니다. 계양테크노밸리는 2차 수도권 주택공급 계획상 약 335만㎡, 17천호 규모로 잡혀 있고, 국토교통부는 인천계양 A2·A3 1106호 공공분양과 2026년 입주 목표를 제시했습니다. 하지만 송도·청라·검단·영종의 기대와 미추홀·동구·부평 원도심 체감은 같은 속도로 움직이지 않습니다.' + else concat(target.display_name, ' 리포트의 출발점은 광역 평균보다 생활권의 실제 쓰임입니다. 수도권은 전세 강세와 월세화, 공급 지연, 광역교통 기대가 동시에 작동하지만 이 힘이 모든 시·구에 같은 방향으로 번지지는 않습니다.') + end as overview, + case + when target.display_name like '%용인시 처인구%' then '처인구는 용인 첨단시스템반도체 국가산단과 원삼·이동 일대 산업 기대가 직접 닿는 곳입니다. 산업 뉴스가 가장 선명하지만 주거 판단은 직주근접 수요, 도로·철도 접근성, 택지·입주 시차를 분리해야 합니다. 첨단시스템반도체 국가산단은 기대 지점이지만, 착공과 고용이 실제 임대차 수요로 연결되는 시간표를 확인해야 합니다.' + when target.display_name like '%용인시 기흥구%' then '기흥구는 플랫폼시티·분당선·경부축 접근성과 반도체 배후 수요가 겹칩니다. 경기남부 산업축을 주거 수요로 연결하기 쉬운 곳이지만, 이미 선호가 반영된 단지와 노후 단지의 체감은 다릅니다.' + when target.display_name like '%용인시 수지구%' then '수지구는 분당·판교 대체 주거지 성격과 신분당선 접근성이 핵심입니다. 용인 반도체 뉴스가 광역 기대를 키우더라도 수지는 산업단지 직접 배후라기보다 강남·판교 접근 주거지로 읽어야 합니다.' + when target.display_name like '%성남시 분당구%' then '분당구는 1기 신도시 정비 기대와 판교 일자리 수요가 결합된 대표 지역입니다. 기본계획 승인 이후 기대는 커졌지만, 선도지구와 후속 단지의 사업성·이주·분담금 차이가 가격을 갈라놓을 수 있습니다.' + when target.display_name like '%성남시 수정구%' then '수정구는 위례·판교·강남 접근성과 원도심 정비가 함께 보이는 지역입니다. 분당 재건축 기대와 같은 결론으로 묶기보다, 신흥·태평·위례 생활권의 수요 성격을 나눠 봐야 합니다.' + when target.display_name like '%성남시 중원구%' then '중원구는 성남 원도심 정비와 산업·업무 접근성이 핵심입니다. 신축 공급 기대보다 재개발 단계, 전세 흡수, 생활 인프라 개선을 먼저 확인해야 합니다.' + when target.display_name like '%수원시 영통구%' then '영통구는 광교·영통·망포 축과 삼성디지털시티 배후 수요가 강합니다. 경기남부 일자리 축의 방어력은 기대 지점이지만, 준신축과 구축 단지 사이 가격 차이가 큽니다.' + when target.display_name like '%수원시 팔달구%' then '팔달구는 수원역·매교·화서 등 원도심 정비와 광역교통이 핵심입니다. 재개발 입주 이후 전세 흡수와 상권 회복을 함께 봐야 합니다.' + when target.display_name like '%수원시 장안구%' then '장안구는 북수원 생활권과 GTX-C 기대, 노후 주거지 개선 이슈가 맞물립니다. 교통 기대는 장기 변수이고 단지별 연식과 학교·상권 접근성이 체감을 가릅니다.' + when target.display_name like '%수원시 권선구%' then '권선구는 서수원 개발과 산업·물류 접근성이 주요 변수입니다. 신규 택지와 기존 구축 주거지의 가격 논리가 달라 평균 지표를 그대로 쓰기 어렵습니다.' + when target.display_name like '%고양시%' then '고양은 일산 1기 신도시 정비와 고양창릉 3기 신도시가 동시에 존재합니다. 일산동·서구는 노후계획도시 정비 단계가 핵심이고, 덕양구는 창릉 공급과 서울 서북권 접근성을 따로 봐야 합니다.' + when target.display_name like '%안양시 동안구%' then '동안구는 평촌 1기 신도시 정비 기대와 범계·평촌 학원가 수요가 결합됩니다. 사업 단계가 빨라질수록 기대는 커지지만 이주와 전세 부담도 같이 봐야 합니다.' + when target.display_name like '%안양시 만안구%' then '만안구는 안양 원도심 정비와 관악·광명·군포 접근성이 핵심입니다. 평촌 기대를 그대로 가져오기보다 정비구역별 사업성과 역세권 생활권을 따로 봐야 합니다.' + when target.display_name like '%군포시%' then '군포는 산본 1기 신도시 선도지구 특별정비구역 지정 흐름이 가장 직접적인 이슈입니다. 경기도는 산본 선도지구를 시작으로 노후계획도시 정비가 본격화됐다고 설명하지만, 실제 주거 판단은 분담금·이주·통합심의 속도에 달려 있습니다.' + when target.display_name like '%부천시%' then '부천은 중동 1기 신도시 정비와 부천대장 3기 신도시 공급을 같이 봐야 합니다. 부천대장은 서울 서부와 김포공항·마곡 인접성이 강점이지만, 중동 1기 신도시는 노후 단지 정비의 시간표가 가격 기대를 가릅니다.' + when target.display_name like '%남양주시%' then '남양주는 왕숙·왕숙2 3기 신도시와 다산·별내·진접 생활권이 겹칩니다. 왕숙은 대규모 공급과 자족 기능을 내세우지만 입주 전까지는 교통 체감과 기존 단지 전세 흐름을 분리해야 합니다.' + when target.display_name like '%하남시%' then '하남은 교산 3기 신도시, 미사 신도시, 강동·송파 접근성이 핵심입니다. 공급 기대와 서울 접근 수요가 동시에 강하지만 교산의 실제 입주 시간표가 체감 가격을 좌우합니다.' + when target.display_name like '%화성시%' then '화성은 동탄2, GTX-A, 삼성·반도체 배후 수요가 결합됩니다. 동탄은 자족도시 기대가 강하지만 대규모 입주와 광역교통 개통 시차가 전세와 매매를 다르게 흔들 수 있습니다.' + when target.display_name like '%평택시%' then '평택은 삼성전자 평택캠퍼스, 고덕국제신도시, 항만·미군기지 수요가 함께 움직입니다. 산업 배후 수요는 뚜렷하지만 공급이 많아 전세 흡수력을 먼저 봐야 합니다.' + when target.display_name like '%과천시%' then '과천은 준강남 입지와 재건축·지식정보타운 공급이 핵심입니다. 희소성과 서울 접근성은 강하지만 작은 표본의 신고가가 전체 시장 판단으로 과장될 수 있습니다.' + when target.display_name like '%광명시%' then '광명은 광명시흥 공공주택지구, 철산·하안 재건축, 서울 서남권 접근성이 겹칩니다. 공급 기대와 구축 정비 기대가 동시에 있어 단기 가격보다 사업 단계별 검증이 중요합니다.' + when target.display_name like '%김포시%' then '김포는 한강신도시, 서울 서부 접근성, 광역교통 개선 기대가 핵심입니다. 교통 호재는 기대 요인이지만 실제 통근 시간과 신규 공급 부담을 함께 확인해야 합니다.' + when target.display_name like '%파주시%' then '파주는 운정신도시와 GTX-A 기대가 중심입니다. 서울 접근성이 개선될수록 관심은 커지지만 대규모 신도시 공급과 전세 흡수 속도를 분리해야 합니다.' + when target.display_name like '%시흥시%' then '시흥은 배곧·은계·장현 신도시와 시화·반월 산업 배후 수요가 같이 움직입니다. 월곶판교선 기대와 산업 수요가 있지만 생활권별 가격 차이가 큽니다.' + when target.display_name like '%안산시%' then '안산은 반월·시화 산업단지와 신안산선 기대, 노후 주거지 정비가 핵심입니다. 산업 회복과 교통 기대가 긍정적이나 구축 비중과 인구 흐름을 함께 봐야 합니다.' + when target.display_name like '%이천시%' then '이천은 SK하이닉스와 물류·산업 수요가 주거 시장을 설명하는 핵심입니다. 산업 뉴스가 임대차 수요로 이어지는 힘은 있지만 외곽 신규 공급과 통근권 경쟁을 확인해야 합니다.' + when target.display_name like '%오산시%' then '오산은 동탄·평택 사이의 가격대 접근성과 세교 공급이 핵심입니다. 경기남부 산업축의 중간 입지지만 자체 일자리보다 주변 도시 의존도가 크다는 점을 봐야 합니다.' + when target.display_name like '%광주시%' then '광주는 판교·분당 배후 주거와 도로 접근성이 핵심입니다. 저밀도 주거와 신축 단지 수요가 있지만 대중교통 접근성과 난개발 체감을 함께 봐야 합니다.' + when target.display_name like '%구리시%' then '구리는 서울 동북권 인접성과 갈매·토평 개발 기대가 핵심입니다. 작은 면적 안에서 공급·정비 뉴스가 가격에 빠르게 반영될 수 있어 표본 수를 봐야 합니다.' + when target.display_name like '%의왕시%' then '의왕은 인덕원·청계·백운밸리와 GTX-C 기대가 주요 변수입니다. 과천·평촌 사이 입지 장점이 있지만 신규 단지와 구축 단지의 가격 논리가 다릅니다.' + when target.display_name like '%의정부시%' then '의정부는 경기북부 행정·상업 중심성과 GTX-C 기대가 핵심입니다. 서울 북부 접근성은 장점이나 경기남부 산업축과 다른 수요 구조를 가진다는 점을 봐야 합니다.' + when target.display_name like '%양주시%' then '양주는 덕정 GTX-C 기대와 옥정·회천 신도시 공급이 핵심입니다. 교통 개선 기대는 크지만 공급 물량이 전세와 매매 흡수 속도를 가를 수 있습니다.' + when target.display_name like '%동두천시%' then '동두천은 경기북부 저가 주거지와 미군기지 이전 이후 도시재생 과제가 같이 놓입니다. 가격대는 접근 가능하지만 인구와 일자리 기반을 먼저 확인해야 합니다.' + when target.display_name like '%포천시%' then '포천은 경기북부 산업·군사·관광 수요가 섞인 외곽 생활권입니다. 개발 기대보다 상주 인구와 교통 접근성 개선 여부가 주거 판단의 핵심입니다.' + when target.display_name like '%연천군%' then '연천은 접경·관광·군 관련 수요가 중심인 저밀도 생활권입니다. 단기 거래 표본이 적어 한두 건의 가격을 시장 방향으로 읽지 않아야 합니다.' + when target.display_name like '%가평군%' then '가평은 관광·세컨드하우스·전원주택 수요가 강한 지역입니다. 외부 수요가 가격을 흔들 수 있지만 상주 수요와 임대차 안정성은 따로 봐야 합니다.' + when target.display_name like '%양평군%' then '양평은 전원주택·세컨드하우스·수도권 동부 접근성이 핵심입니다. 교통 개선 기대와 생활인구 수요가 있으나 실거주 편의와 의료·교육 접근성을 확인해야 합니다.' + when target.display_name like '%여주시%' then '여주는 수도권 동남부 외곽의 물류·전원 주거 수요가 중심입니다. 가격대는 접근 가능하지만 거래 표본과 신규 공급을 함께 봐야 합니다.' + when target.display_name like '%안성시%' then '안성은 평택·용인 산업축의 배후와 자체 물류·제조 수요가 섞입니다. 산업 뉴스는 기대 요인이지만 통근권 경쟁과 공급 흡수를 같이 봐야 합니다.' + when target.display_name like '%인천광역시 계양구%' then '계양구는 계양테크노밸리 3기 신도시가 핵심입니다. 인천시 중장기 계획은 계양구 귤현·동양·박촌·병방·상야동 일원의 약 335만㎡, 17천호 계획을 제시했고, 국토교통부는 A2·A3 1106호 분양과 2026년 입주 목표를 공개했습니다.' + when target.display_name like '%인천광역시 서구%' then '서구는 검단신도시와 청라, 루원·가정 원도심 재편이 한 화면에 있습니다. 검단 공급은 가격 안정 요인이 될 수 있지만 청라·검단·원도심의 수요 성격은 다릅니다.' + when target.display_name like '%인천광역시 연수구%' then '연수구는 송도국제도시와 GTX-B 기대, 바이오·국제업무 수요가 핵심입니다. 고급 주거 수요는 강하지만 국제업무·상업 공실과 주거 가격을 같은 방향으로 단정하면 안 됩니다.' + when target.display_name like '%인천광역시 중구%' then '중구는 영종·공항경제권과 인천 원도심이 함께 있는 이중 구조입니다. 공항·관광 회복은 기대 요인이지만 영종과 내륙 원도심의 전세·매매 흐름은 다르게 봐야 합니다.' + when target.display_name like '%인천광역시 부평구%' then '부평구는 GTX-B 기대, 부평역 상권, 원도심 재개발이 핵심입니다. 서울 접근 개선 기대가 강하지만 구축 비중과 정비 단계가 체감을 나눕니다.' + when target.display_name like '%인천광역시 남동구%' then '남동구는 구월2지구 기대, 남동산단 배후, 만수·간석 원도심 정비가 같이 놓입니다. 산업·행정 수요는 안정 요인이지만 노후 단지 개선 속도를 따로 봐야 합니다.' + when target.display_name like '%인천광역시 미추홀구%' or target.display_name like '%인천광역시 남구%' then '미추홀구는 용현·학익, 주안·도화 원도심 정비가 중심입니다. 신도시보다 정비사업 단계와 전세 안정성이 더 중요한 권역입니다.' + when target.display_name like '%인천광역시 동구%' then '동구는 항만·원도심·노후 주거지가 중심입니다. 대형 신도시 호재보다 도시재생, 생활SOC, 산업 배후 수요가 주거 체감을 좌우합니다.' + when target.display_name like '%인천광역시 강화군%' then '강화군은 관광·전원주택·군 단위 생활권 수요가 핵심입니다. 인천 신도시 공급 논리와 분리해 상주 수요, 교통, 생활 인프라를 봐야 합니다.' + when target.display_name like '%인천광역시 옹진군%' then '옹진군은 섬 지역 생활권과 관광·어업 수요가 중심입니다. 거래 표본이 적어 가격 데이터의 신뢰구간을 보수적으로 봐야 합니다.' + else concat(target.display_name, '는 수도권 광역 흐름을 받지만, 실제 판단은 지역별 교통·일자리·공급 일정으로 좁혀야 합니다.') + end as local_signal, + case + when target.display_name like '경기도%' or target.display_name = '경기도' then '정책 신호는 2026년 수도권 공공택지 5만호 이상 착공, 3기 신도시 입주 시작, 경기도 1기 신도시 정비기본계획 승인, 용인 반도체(첨단시스템반도체 국가산단) 축으로 나뉩니다.' + else '정책 신호는 인천계양 3기 신도시의 17천호 계획, A2·A3 1106호 공공분양과 2026년 입주 목표, 검단·청라·송도 공급·개발축, 원도심 정비 속도 차이로 나뉩니다.' + end as policy_signal, + case + when target.display_name like '%계양구%' then '계양테크노밸리 공급이 서울 서부권 수요와 인천 원도심 수요를 동시에 흡수할 수 있는 점' + when target.display_name like '%처인구%' then '첨단시스템반도체 국가산단이 장기 일자리와 주거 수요의 근거가 될 수 있는 점' + when target.display_name like '%부천시%' then '부천대장 3기 신도시와 중동 1기 신도시 정비를 함께 비교할 수 있는 점' + when target.display_name like '%분당구%' or target.display_name like '%일산%' or target.display_name like '%동안구%' or target.display_name like '%군포시%' then '1기 신도시 정비가 실제 특별정비계획과 이주 계획으로 이어질 수 있는 점' + when target.display_name like '%송도%' or target.display_name like '%연수구%' then '국제업무·바이오·GTX-B 기대가 주거 선호를 받칠 수 있는 점' + else '교통·일자리·공급 근거가 실거래와 전세 데이터로 검증될 수 있는 점' + end as expectation_theme, + case + when target.display_name like '%계양구%' then '입주 전 기대가 주변 구축 가격에 먼저 반영되고 공급 흡수 시차가 생길 수 있다는 점' + when target.display_name like '%처인구%' then '산업 뉴스가 실제 고용·임대차 수요로 연결되기까지 시간이 걸릴 수 있다는 점' + when target.display_name like '%부천시%' then '부천대장 공급 기대와 중동 1기 신도시 정비 기대가 서로 다른 시간표로 움직인다는 점' + when target.display_name like '%분당구%' or target.display_name like '%일산%' or target.display_name like '%동안구%' or target.display_name like '%군포시%' then '정비 기대가 분담금·이주·사업시행인가 검증보다 먼저 가격에 반영될 수 있다는 점' + when target.display_name like '%인천광역시%' then '신도시와 원도심의 수요 격차가 커 광역 평균이 체감을 가릴 수 있다는 점' + else '개발 기대와 신규 공급 부담, 전세 흡수력이 서로 다른 시차로 움직일 수 있다는 점' + end as concern_theme + from real_estate_regions region + join real_estate_targets target on target.id = region.target_id + left join real_estate_targets parent_target on parent_target.id = region.parent_region_id + where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '경기도%' + or target.display_name like '인천광역시%' + or parent_target.display_name in ('경기도', '인천광역시') + or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000') + ) +) researched +left join real_estate_regional_reports existing on existing.target_id = researched.target_id +on duplicate key update + report_id = values(report_id), + report_version = values(report_version), + prompt_version = values(prompt_version), + model_name = values(model_name), + generated_by = values(generated_by), + title = values(title), + headline = values(headline), + summary = values(summary), + body = values(body), + expectation_points_json = values(expectation_points_json), + concern_points_json = values(concern_points_json), + data_quality = values(data_quality), + confidence = values(confidence), + as_of = values(as_of), + published_at = values(published_at), + updated_at = values(updated_at); + +delete from real_estate_regional_report_sources +where report_target_id in ( + select region.target_id + from real_estate_regions region + join real_estate_targets target on target.id = region.target_id + left join real_estate_targets parent_target on parent_target.id = region.parent_region_id + where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '경기도%' + or target.display_name like '인천광역시%' + or parent_target.display_name in ('경기도', '인천광역시') + or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000') + ) +); + +insert into real_estate_regional_report_sources ( + id, + report_target_id, + display_order, + ref_type, + ref_id, + label, + title, + url, + source_name, + published_at, + data_status, + created_at, + updated_at +) +select + concat('regional-report-source-', region.target_id, '-capital-01-kb-report'), + region.target_id, + 1, + 'external', + 'kb-2026-real-estate-report', + '금융권 리포트', + '2026 KB 부동산 보고서', + 'https://www.kbfg.com/kbresearch/brand/brandView.do?reportId=2000492', + 'KB경영연구소', + '2026-05-05 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '경기도%' + or target.display_name like '인천광역시%' + or parent_target.display_name in ('경기도', '인천광역시') + or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-capital-02-kdi-market'), + region.target_id, + 2, + 'external', + 'kdi-2026-q1-real-estate-market', + '시장 동향', + 'KDI 경제정보센터 2026년 1분기 부동산시장동향', + 'https://eiec.kdi.re.kr/policy/domesticView.do?ac=0000204646&depth1=&issus=&pg=&pp=&search_txt=&type=', + 'KDI 경제정보센터', + '2026-06-01 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '경기도%' + or target.display_name like '인천광역시%' + or parent_target.display_name in ('경기도', '인천광역시') + or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-capital-03-cerik'), + region.target_id, + 3, + 'external', + 'cerik-2026-q1-real-estate-market', + '건설산업 리포트', + '한국건설산업연구원 2026년 1분기 부동산시장동향', + 'https://www.cerik.re.kr/uploads/report/3077/%EB%B6%80%EB%8F%99%EC%82%B0%EC%8B%9C%EC%9E%A5%EB%8F%99%ED%96%A5%202026%EB%85%84%201%EB%B6%84%EA%B8%B0_2026-04-13.pdf', + '한국건설산업연구원', + '2026-04-13 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '경기도%' + or target.display_name like '인천광역시%' + or parent_target.display_name in ('경기도', '인천광역시') + or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-capital-04-rone'), + region.target_id, + 4, + 'external', + 'reb-rone-2026-05-national-housing-price-trend', + '공식 가격지표', + '한국부동산원 R-ONE 2026년 5월 전국주택가격동향', + 'https://www.reb.or.kr/r-one/portal/main/indexPage.do', + '한국부동산원', + '2026-06-15 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '경기도%' + or target.display_name like '인천광역시%' + or parent_target.display_name in ('경기도', '인천광역시') + or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-capital-05-molit-2026-supply'), + region.target_id, + 5, + 'external', + 'molit-2026-capital-public-land-50000', + '수도권 공급정책', + '정책브리핑 내년 수도권 공공택지 5만가구 이상 착공', + 'https://www.korea.kr/news/policyNewsView.do?newsId=148956384', + '국토교통부', + '2025-12-12 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '경기도%' + or target.display_name like '인천광역시%' + or parent_target.display_name in ('경기도', '인천광역시') + or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-gyeonggi-06-first-newtown'), + region.target_id, + 6, + 'external', + 'gyeonggi-first-newtown-master-plan-20250528', + '경기 1기 신도시', + '경기도 분당·일산 기본계획 승인, 1기 신도시 5곳 정비 본격화', + 'https://gnews.gg.go.kr/briefing/brief_gongbo_view.do?BS_CODE=s017&number=66080', + '경기도뉴스포털', + '2025-05-28 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '경기도%' + or parent_target.display_name = '경기도' + or region.target_id in ('region-gyeonggi', 'region-41000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-gyeonggi-07-sanbon-special'), + region.target_id, + 7, + 'external', + 'gyeonggi-sanbon-special-renewal-zone-20260113', + '경기 정비사업', + '경기도 1기 신도시 선도지구 특별정비구역 첫 지정 군포·산본', + 'https://gnews.gg.go.kr/briefing/brief_gongbo_view.do?BS_CODE=s017&number=68984', + '경기도뉴스포털', + '2026-01-13 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '경기도%' + or parent_target.display_name = '경기도' + or region.target_id in ('region-gyeonggi', 'region-41000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-gyeonggi-08-third-newtown'), + region.target_id, + 8, + 'external', + 'third-newtown-official-notice-2026', + '3기 신도시', + '3기 신도시 고시·공고 목록', + 'https://www.xn--3-3u6ey6lv7rsa.kr/kor/CMS/Board/Board.do?mCode=MN111', + '한국토지주택공사', + '2026-05-21 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '경기도%' + or parent_target.display_name = '경기도' + or region.target_id in ('region-gyeonggi', 'region-41000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-gyeonggi-09-yongin-semicon'), + region.target_id, + 9, + 'external', + 'molit-yongin-semiconductor-national-industrial-complex', + '경기 산업축', + '국토교통부 용인 반도체 국가산업단지 지정', + 'https://www.molit.go.kr/USR/NEWS/m_71/dtl.jsp?id=95090534&lcmspage=7', + '국토교통부', + '2024-12-26 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '경기도%' + or parent_target.display_name = '경기도' + or region.target_id in ('region-gyeonggi', 'region-41000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-gyeonggi-10-bucheon-daejang'), + region.target_id, + 10, + 'external', + 'third-newtown-bucheon-daejang-overview', + '부천대장 근거', + '3기 신도시 부천대장 지구개요', + 'https://www.xn--3-3u6ey6lv7rsa.kr/kor/CMS/Contents/Contents.do?mCode=MN068', + '한국토지주택공사', + null, + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '경기도%' + or parent_target.display_name = '경기도' + or region.target_id in ('region-gyeonggi', 'region-41000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-incheon-06-capital-plan'), + region.target_id, + 6, + 'external', + 'incheon-capital-housing-supply-plan-gyeyang-tv', + '인천 공급계획', + '인천광역시 수도권 주택공급 계획', + 'https://www.incheon.go.kr/open/OPEN020301/view?attachFileSeq=FILE_000000000062056&curPage=9&planSeq=DOM_0000000000099714', + '인천광역시', + '2025-08-28 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '인천광역시%' + or parent_target.display_name = '인천광역시' + or region.target_id in ('region-incheon', 'region-28000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-incheon-07-gyeyang-1106'), + region.target_id, + 7, + 'external', + 'molit-incheon-gyeyang-1106-public-sale', + '계양 공급', + '정책브리핑 인천계양 공공주택 1106호 분양', + 'https://www.korea.kr/news/policyNewsView.do?newsId=148933414', + '국토교통부', + '2024-09-03 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '인천광역시%' + or parent_target.display_name = '인천광역시' + or region.target_id in ('region-incheon', 'region-28000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-incheon-08-gyeyang-change'), + region.target_id, + 8, + 'external', + 'third-newtown-incheon-gyeyang-plan-change-20260105', + '계양 지구계획', + '3기 신도시 인천계양테크노밸리 지구계획 변경 승인', + 'https://xn--3-3u6ey6lv7rsa.kr/kor/CMS/Board/Board.do?board_seq=445&mCode=MN111&mgr_seq=13&mode=view', + '한국토지주택공사', + '2026-01-05 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '인천광역시%' + or parent_target.display_name = '인천광역시' + or region.target_id in ('region-incheon', 'region-28000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-incheon-09-ih-news'), + region.target_id, + 9, + 'external', + 'incheon-housing-city-corporation-news-2026', + '인천 개발사업', + '인천도시공사 보도자료 목록', + 'https://www.ih.co.kr/main/public/news_data.jsp', + '인천도시공사', + '2026-06-22 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '인천광역시%' + or parent_target.display_name = '인천광역시' + or region.target_id in ('region-incheon', 'region-28000') + ) +union all +select + concat('regional-report-source-', region.target_id, '-incheon-10-third-newtown'), + region.target_id, + 10, + 'external', + 'third-newtown-official-notice-2026', + '3기 신도시', + '3기 신도시 고시·공고 목록', + 'https://www.xn--3-3u6ey6lv7rsa.kr/kor/CMS/Board/Board.do?mCode=MN111', + '한국토지주택공사', + '2026-05-21 00:00:00', + 'ok', + '2026-06-24 23:45:00', + '2026-06-24 23:45:00' +from real_estate_regions region +join real_estate_targets target on target.id = region.target_id +left join real_estate_targets parent_target on parent_target.id = region.parent_region_id +where region.region_level in ('sido', 'sigungu') + and ( + target.display_name like '인천광역시%' + or parent_target.display_name = '인천광역시' + or region.target_id in ('region-incheon', 'region-28000') + ); diff --git a/backend/src/main/resources/db/migration/V55__real_estate_nationwide_deep_research_reports.sql b/backend/src/main/resources/db/migration/V55__real_estate_nationwide_deep_research_reports.sql new file mode 100644 index 0000000..05cb7b4 --- /dev/null +++ b/backend/src/main/resources/db/migration/V55__real_estate_nationwide_deep_research_reports.sql @@ -0,0 +1,339 @@ +insert into real_estate_regional_reports ( + target_id, + report_id, + report_version, + prompt_version, + model_name, + generated_by, + title, + headline, + summary, + body, + expectation_points_json, + concern_points_json, + data_quality, + confidence, + as_of, + published_at, + created_at, + updated_at +) +select + researched.target_id, + concat('regional-report-', researched.target_id, '-deep-national-20260624'), + 'regional-report-deep-research-v1', + 'codex-national-regional-deep-research-prompt-20260624', + 'codex-gpt-5-deep-research', + 'codex-deep-research:national-regional-20260624', + concat(researched.display_name, ' 심화 지역 리포트'), + researched.headline, + researched.summary, + concat( + researched.overview, ' ', + researched.local_signal, ' ', + '공통 시장 배경은 KB·KDI·주택산업연구원·건설산업연구원 자료가 함께 말하는 2026년의 양극화입니다. 수도권은 수요 회복과 전세 압력이 강하고, 지방은 공급 부담·미분양·산업 경기·생활인구 변화가 지역별로 다르게 작동합니다. ', + researched.policy_signal, ' ', + '기대 지점은 ', researched.expectation_theme, '입니다. 우려 지점은 ', researched.concern_theme, '입니다. ', + '지도 기간별 등락률은 단기 보조 신호로만 쓰고, 이 본문은 최신 공식 통계·정책·지역 이슈가 갱신되면 같은 target의 최신본으로 다시 교체되는 저장 리포트입니다.' + ), + concat( + '["', + researched.expectation_theme, '",', + '"실거래·전세·공급·정책 근거를 같은 지역 target에 붙여 이후 AI API 자동 갱신의 기준점으로 쓸 수 있습니다.",', + '"지역별 산업·교통·생활인구 이슈를 가격 방향의 단정이 아니라 관찰 근거로 분리했습니다."]' + ), + concat( + '["', + researched.concern_theme, '",', + '"미분양·입주·정비·산업 뉴스가 실제 거래와 전세에 반영되는 시차를 확인해야 합니다.",', + '"확인되지 않은 단기 뉴스나 개발 기대를 매수·매도 판단처럼 쓰지 않습니다."]' + ), + 'deep_researched', + case + when researched.region_level = 'sido' then 0.82 + else 0.78 + end, + '2026-06-24 00:00:00', + '2026-06-24 23:55:00', + coalesce(existing.created_at, '2026-06-24 23:55:00'), + '2026-06-24 23:55:00' +from ( + select + region.target_id, + region.region_level, + target.display_name, + case + when target.display_name like '부산광역시%' then concat(target.display_name, '는 북항·가덕도·동부산 선호와 원도심 정비를 분리해 봐야 하는 해양도시 생활권입니다.') + when target.display_name like '대구광역시%' then concat(target.display_name, '는 미분양 해소와 준공 후 미분양, 신공항·산업 기대를 동시에 봐야 하는 공급 부담 생활권입니다.') + when target.display_name like '광주광역시%' then concat(target.display_name, '는 첨단산단·도심 정비·전세 수요를 함께 읽어야 하는 호남권 핵심 생활권입니다.') + when target.display_name like '대전광역시%' then concat(target.display_name, '는 연구개발·공공기관 수요와 세종 연계, 도안권 공급을 함께 봐야 하는 충청권 핵심 생활권입니다.') + when target.display_name like '울산광역시%' then concat(target.display_name, '는 자동차·조선·석유화학 경기와 주거 수요가 밀접한 산업도시 생활권입니다.') + when target.display_name like '세종특별자치시%' then concat(target.display_name, '는 행정수도 기능과 5생활권 공급이 동시에 움직이는 정책도시 생활권입니다.') + when target.display_name like '강원도%' then concat(target.display_name, '는 관광·생활인구·혁신도시·광역교통 기대를 나눠 봐야 하는 강원권 생활권입니다.') + when target.display_name like '충청북도%' then concat(target.display_name, '는 청주·오송·오창 산업축과 중부내륙 생활권을 분리해야 하는 충북권 생활권입니다.') + when target.display_name like '충청남도%' then concat(target.display_name, '는 천안·아산 수도권 연계와 서해안 산업벨트를 같이 봐야 하는 충남권 생활권입니다.') + when target.display_name like '전라북도%' then concat(target.display_name, '는 전주 생활수요와 새만금·군산·익산 산업축을 함께 확인해야 하는 전북권 생활권입니다.') + when target.display_name like '전라남도%' then concat(target.display_name, '는 여수·순천·광양 산업축과 목포·나주·해안 관광권을 분리해야 하는 전남권 생활권입니다.') + when target.display_name like '경상북도%' then concat(target.display_name, '는 포항·구미 산업축과 대구경북신공항·관광 생활권이 다른 속도로 움직이는 경북권 생활권입니다.') + when target.display_name like '경상남도%' then concat(target.display_name, '는 창원 제조업, 김해·양산 부산권 연계, 거제·통영 조선·관광 흐름을 나눠 봐야 하는 경남권 생활권입니다.') + when target.display_name like '제주특별자치도%' then concat(target.display_name, '는 관광·이주·건설경기·주택 통계를 함께 봐야 하는 섬 생활권입니다.') + else concat(target.display_name, '는 광역 흐름과 자체 생활권 수요를 나눠 봐야 하는 지역입니다.') + end as headline, + concat(target.display_name, '는 최신 시장 리포트, 공식 가격·거래 원천, 지역 정책 자료를 함께 엮어 읽는 최신 기준 지역 리포트입니다.') as summary, + case + when target.display_name like '부산광역시%' then '부산권의 핵심은 북항 재개발, 가덕도 신공항, 해운대·수영·동래의 동부산 선호, 서부산·원도심 정비가 같은 도시 안에서 다른 속도로 움직인다는 점입니다. 부산시는 북항을 유라시아 게이트웨이와 국제해양관광 거점으로 조성하고 원도심 기능 회복을 도모한다고 설명합니다.' + when target.display_name like '대구광역시%' then '대구권의 핵심은 미분양 총량이 줄어드는 신호와 준공 후 미분양 부담, 수성구·중구 핵심지와 외곽 공급지의 양극화입니다. 대구시는 2026년 1월부터 4월까지 공동주택 미분양 현황을 월별로 공개하고 있어, 이 지역은 가격보다 미분양의 질과 입주 부담을 먼저 봐야 합니다.' + when target.display_name like '세종특별자치시%' then '세종권의 핵심은 행정수도 기대가 다시 커지는 가운데 5생활권 공급이 실제 수요를 시험한다는 점입니다. 행복청과 세종시는 2026년 행복도시 4-2·5-1·5-2생활권에 총 4740호, 이 중 분양주택 4225호 공급 계획을 공개했습니다.' + when target.display_name like '제주특별자치도%' then '제주권의 핵심은 관광 회복과 이주 수요, 건설경기 둔화, 주택 통계의 작은 표본성이 함께 존재한다는 점입니다. 제주 주택 통계는 월별로 공개되지만 제주시와 서귀포, 읍면 지역의 체감은 크게 다를 수 있습니다.' + when target.display_name like '강원도%' then '강원권은 춘천·원주 생활수요, 강릉·속초 관광·세컨드하우스, 폐광·접경 지역의 인구 과제가 동시에 있는 지역입니다. 생활인구 기대와 상주 수요를 분리하지 않으면 가격 해석이 흔들립니다.' + when target.display_name like '충청북도%' then '충북권은 청주·오송·오창의 바이오·반도체·철도 접근성과 충주·제천·괴산·단양의 중부내륙 생활권이 다르게 움직입니다. 산업 뉴스는 기대 요인이지만 전세와 입주 수요로 확인해야 합니다.' + when target.display_name like '충청남도%' then '충남권은 천안·아산의 수도권 연계, 당진·서산의 산업벨트, 내포신도시와 서해안 관광권이 나뉘는 시장입니다. 산업 배후 수요와 신규 공급 흡수력을 같이 봐야 합니다.' + when target.display_name like '전라북도%' then '전북권은 전주권 생활수요, 새만금과 군산 산업축, 익산·완주 혁신도시 연계가 핵심입니다. 개발 기대가 실제 주거 수요로 바뀌는 속도를 보수적으로 봐야 합니다.' + when target.display_name like '전라남도%' then '전남권은 여수·순천·광양 산업축, 나주 혁신도시, 목포·무안·해남 해안권이 다르게 움직입니다. 산업도시와 군 단위 생활권은 같은 지표로 판단하기 어렵습니다.' + when target.display_name like '경상북도%' then '경북권은 포항·구미 제조업, 안동·예천 행정·신도시 수요, 경주 관광, 대구경북신공항 기대가 권역별로 다르게 작동합니다. 호재보다 실제 인구와 일자리 흐름이 중요합니다.' + when target.display_name like '경상남도%' then '경남권은 창원 제조업, 김해·양산 부산권 배후, 거제·통영 조선·관광, 진주·사천 항공우주 흐름을 나눠 봐야 합니다. 산업 회복은 기대지만 지역별 인구 체력은 다릅니다.' + else concat(target.display_name, '의 핵심은 공식 가격지표, 실거래, 전세, 공급 일정이 서로 같은 방향으로 움직이는지 확인하는 것입니다.') + end as overview, + case + when target.display_name like '%해운대%' then '해운대는 부산 안에서도 해안 주거 선호와 마린시티·센텀 업무, 관광 수요가 결합된 대표 권역입니다. 북항 개발 기대가 부산 전체 분위기를 만들더라도 해운대 가격은 동부산 고급 주거와 전세 수요로 따로 움직일 수 있습니다.' + when target.display_name like '%수영%' then '수영구는 광안리 해안 상권과 남천·수영 주거 선호가 강한 권역입니다. 관광·상권 뉴스와 상주 주거 수요를 구분해야 합니다.' + when target.display_name like '%동구%' and target.display_name like '부산광역시%' then '부산 동구는 북항·부산역·원도심 재생의 직접 영향권입니다. 기대는 크지만 상업·업무 개발과 아파트 실수요가 같은 속도로 움직이지 않을 수 있습니다.' + when target.display_name like '%강서구%' and target.display_name like '부산광역시%' then '부산 강서구는 에코델타, 산업단지, 가덕도 신공항 기대가 겹칩니다. 장기 개발축은 분명하지만 입주와 교통 체감 시차를 확인해야 합니다.' + when target.display_name like '%수성구%' then '수성구는 대구 핵심 학군·주거 선호가 남아 있는 지역입니다. 다만 대구 전체의 미분양 부담과 고분양가 흐름이 완전히 사라진 것은 아니어서, 수성구 프리미엄과 외곽 부담을 함께 봐야 합니다.' + when target.display_name like '%달서구%' then '달서구는 월배·상인·죽전 생활권과 산업·상업 수요가 섞입니다. 대구 미분양이 줄어도 준공 후 물량과 구축 단지 가격 차이가 남을 수 있습니다.' + when target.display_name like '%달성군%' then '달성군은 테크노폴리스·산업단지·신공항 기대가 있지만 외곽 공급 부담도 함께 있습니다. 산업 뉴스보다 실제 통근과 입주 흡수력을 봐야 합니다.' + when target.display_name like '%세종%' then '세종은 행정수도 기능, 5생활권 분양 4225호, 공공임대 515호가 같이 움직입니다. 공급이 다시 열리는 만큼 기대보다 청약·입주 흡수력을 먼저 확인해야 합니다.' + when target.display_name like '%제주시%' then '제주시는 행정·상업·공항 접근성이 강하지만 관광과 이주 수요가 동시에 가격을 흔듭니다. 월별 주택 통계와 거래 표본을 함께 봐야 합니다.' + when target.display_name like '%서귀포%' then '서귀포는 관광·휴양·농촌 생활권 수요가 중심입니다. 외부 수요가 커질 수 있지만 상주 수요와 임대차 안정성은 제주시와 다릅니다.' + when target.display_name like '%춘천%' then '춘천은 수도권 접근성과 행정·대학 수요, 관광 수요가 섞입니다. 교통 개선 기대와 실제 통근 수요를 나눠 봐야 합니다.' + when target.display_name like '%원주%' then '원주는 혁신도시·기업도시와 의료·교육 수요가 핵심입니다. 강원권 안에서도 상주 수요 기반이 상대적으로 뚜렷합니다.' + when target.display_name like '%강릉%' or target.display_name like '%속초%' then '영동권은 관광·세컨드하우스 수요가 강하지만 외부 수요 의존도가 큽니다. 계절성과 상주 수요를 분리해야 합니다.' + when target.display_name like '%청주%' then '청주는 오송·오창 산업축과 충북의 중심 생활수요가 결합됩니다. 산업 기대는 강하지만 분양·입주 부담을 같이 봐야 합니다.' + when target.display_name like '%천안%' or target.display_name like '%아산%' then '천안·아산은 수도권 남부와 충남 산업축을 잇는 대표 생활권입니다. 광역교통과 일자리 수요가 전세와 매매를 같이 받치는지 확인해야 합니다.' + when target.display_name like '%전주시%' then '전주는 전북 핵심 주거 수요와 혁신도시 연계가 중심입니다. 새만금 기대와 전주 생활수요를 같은 결론으로 묶지 않는 것이 중요합니다.' + when target.display_name like '%군산%' then '군산은 새만금과 산업단지 기대가 핵심입니다. 산업 뉴스가 주택 수요로 전환되는 속도를 확인해야 합니다.' + when target.display_name like '%여수%' or target.display_name like '%순천%' or target.display_name like '%광양%' then '여수·순천·광양은 산업단지와 관광·생활권이 같이 움직이는 전남 핵심축입니다. 산업 경기와 상주 수요를 함께 봐야 합니다.' + when target.display_name like '%포항%' then '포항은 철강·배터리·해양산업 기대와 지진 이후 주거 안정성 인식이 함께 작동합니다. 산업 회복과 인구 흐름을 같이 확인해야 합니다.' + when target.display_name like '%구미%' then '구미는 전자·첨단산업 기반이 강하지만 산업 경기 변동에 민감합니다. 일자리 회복이 전세와 매매 수요로 확인되는지가 핵심입니다.' + when target.display_name like '%창원%' then '창원은 제조업과 노후 주거지 정비, 특례시 생활권 수요가 핵심입니다. 산업 회복과 구축 단지 개선 속도를 같이 봐야 합니다.' + when target.display_name like '%김해%' or target.display_name like '%양산%' then '김해·양산은 부산권 배후 주거와 자체 산업 수요가 섞입니다. 부산 접근성 기대와 신규 공급 부담을 분리해야 합니다.' + when target.display_name like '%거제%' or target.display_name like '%통영%' then '거제·통영은 조선업과 관광 수요에 민감합니다. 업황 회복이 주거 수요 안정으로 이어지는지 확인해야 합니다.' + else concat(target.display_name, '는 해당 광역권 안에서도 실거래 표본, 전세 흐름, 공급 일정, 생활권 수요를 따로 확인해야 합니다.') + end as local_signal, + case + when target.display_name like '부산광역시%' then '정책·이슈 신호는 부산 북항 재개발, 가덕도 신공항, 원도심 재생, 동부산 주거 선호로 나뉩니다.' + when target.display_name like '대구광역시%' then '정책·이슈 신호는 대구시 월별 미분양 자료, 준공 후 미분양 부담, 신공항·산업 기대, 수성구 핵심지 선호로 나뉩니다.' + when target.display_name like '세종특별자치시%' then '정책·이슈 신호는 행정수도 기능, 5-1·5-2생활권 공급, 2026년 행복도시 4740호 공급 계획으로 나뉩니다.' + when target.display_name like '제주특별자치도%' then '정책·이슈 신호는 제주 주택 통계, 관광 회복, 건설경기 회복 정책, 제주시·서귀포 수요 차이로 나뉩니다.' + else '정책·이슈 신호는 광역 교통, 산업단지, 도심 정비, 미분양·입주 부담, 생활인구 변화로 나뉩니다.' + end as policy_signal, + case + when target.display_name like '부산광역시%' then '북항·가덕도·동부산 선호가 실거래와 전세에서 따로 검증될 수 있는 점' + when target.display_name like '대구광역시%' then '미분양 부담이 실제로 줄고 핵심 생활권 수요가 먼저 회복되는지 확인할 수 있는 점' + when target.display_name like '세종특별자치시%' then '행정수도 기대와 5생활권 공급이 청약·입주 수요로 검증될 수 있는 점' + when target.display_name like '제주특별자치도%' then '관광 회복과 주택 통계를 함께 보며 외부 수요와 상주 수요를 나눠 볼 수 있는 점' + else '지역의 산업·교통·생활권 변화가 공식 가격지표와 실거래에서 확인될 수 있는 점' + end as expectation_theme, + case + when target.display_name like '부산광역시%' then '개발 기대가 원도심·동부산·서부산의 실제 주거 수요 차이를 가릴 수 있다는 점' + when target.display_name like '대구광역시%' then '미분양 감소만 보고 준공 후 미분양과 고분양가 부담을 과소평가할 수 있다는 점' + when target.display_name like '세종특별자치시%' then '정책 기대가 공급 흡수력보다 먼저 가격에 반영될 수 있다는 점' + when target.display_name like '제주특별자치도%' then '관광·이주 수요가 상주 주거 수요와 다르게 움직일 수 있다는 점' + else '단일 개발 뉴스가 실제 거래와 전세 회복보다 먼저 가격 기대를 만들 수 있다는 점' + end as concern_theme + from real_estate_regions region + join real_estate_targets target on target.id = region.target_id + join real_estate_regional_reports existing on existing.target_id = region.target_id + where region.region_level in ('sido', 'sigungu') + and existing.data_quality <> 'deep_researched' +) researched +left join real_estate_regional_reports existing on existing.target_id = researched.target_id +on duplicate key update + report_id = values(report_id), + report_version = values(report_version), + prompt_version = values(prompt_version), + model_name = values(model_name), + generated_by = values(generated_by), + title = values(title), + headline = values(headline), + summary = values(summary), + body = values(body), + expectation_points_json = values(expectation_points_json), + concern_points_json = values(concern_points_json), + data_quality = values(data_quality), + confidence = values(confidence), + as_of = values(as_of), + published_at = values(published_at), + updated_at = values(updated_at); + +delete from real_estate_regional_report_sources +where report_target_id in ( + select existing.target_id + from real_estate_regional_reports existing + where existing.generated_by = 'codex-deep-research:national-regional-20260624' +); + +insert into real_estate_regional_report_sources ( + id, + report_target_id, + display_order, + ref_type, + ref_id, + label, + title, + url, + source_name, + published_at, + data_status, + created_at, + updated_at +) +select + concat('regional-report-source-', report.target_id, '-national-01-kb-report'), + report.target_id, + 1, + 'external', + 'kb-2026-real-estate-report', + '금융권 리포트', + '2026 KB 부동산 보고서', + 'https://www.kbfg.com/kbresearch/brand/brandView.do?reportId=2000492', + 'KB경영연구소', + '2026-05-05 00:00:00', + 'ok', + '2026-06-24 23:55:00', + '2026-06-24 23:55:00' +from real_estate_regional_reports report +where report.generated_by = 'codex-deep-research:national-regional-20260624' +union all +select + concat('regional-report-source-', report.target_id, '-national-02-kdi-market'), + report.target_id, + 2, + 'external', + 'kdi-2026-q1-real-estate-market', + '시장 동향', + 'KDI 경제정보센터 2026년 1분기 부동산시장동향', + 'https://eiec.kdi.re.kr/policy/domesticView.do?ac=0000204646&depth1=&issus=&pg=&pp=&search_txt=&type=', + 'KDI 경제정보센터', + '2026-06-01 00:00:00', + 'ok', + '2026-06-24 23:55:00', + '2026-06-24 23:55:00' +from real_estate_regional_reports report +where report.generated_by = 'codex-deep-research:national-regional-20260624' +union all +select + concat('regional-report-source-', report.target_id, '-national-03-khi'), + report.target_id, + 3, + 'external', + 'khi-2026-housing-market-outlook', + '주택시장 전망', + '주택산업연구원 2026년 주택시장 전망', + 'https://www.khi.re.kr/info/info3.php?boardid=board4&idx=44&mode=view&offset=&sk=&sw=', + '주택산업연구원', + '2025-12-23 00:00:00', + 'ok', + '2026-06-24 23:55:00', + '2026-06-24 23:55:00' +from real_estate_regional_reports report +where report.generated_by = 'codex-deep-research:national-regional-20260624' +union all +select + concat('regional-report-source-', report.target_id, '-national-04-cerik'), + report.target_id, + 4, + 'external', + 'cerik-2026-q1-real-estate-market', + '건설산업 리포트', + '한국건설산업연구원 2026년 1분기 부동산시장동향', + 'https://www.cerik.re.kr/uploads/report/3077/%EB%B6%80%EB%8F%99%EC%82%B0%EC%8B%9C%EC%9E%A5%EB%8F%99%ED%96%A5%202026%EB%85%84%201%EB%B6%84%EA%B8%B0_2026-04-13.pdf', + '한국건설산업연구원', + '2026-04-13 00:00:00', + 'ok', + '2026-06-24 23:55:00', + '2026-06-24 23:55:00' +from real_estate_regional_reports report +where report.generated_by = 'codex-deep-research:national-regional-20260624' +union all +select + concat('regional-report-source-', report.target_id, '-national-05-rone'), + report.target_id, + 5, + 'external', + 'reb-rone-2026-05-national-housing-price-trend', + '공식 가격지표', + '한국부동산원 R-ONE 2026년 5월 전국주택가격동향', + 'https://www.reb.or.kr/r-one/portal/main/indexPage.do', + '한국부동산원', + '2026-06-15 00:00:00', + 'ok', + '2026-06-24 23:55:00', + '2026-06-24 23:55:00' +from real_estate_regional_reports report +where report.generated_by = 'codex-deep-research:national-regional-20260624' +union all +select + concat('regional-report-source-', report.target_id, '-national-06-molit-regional-policy'), + report.target_id, + 6, + 'external', + 'molit-2026-regional-policy-five-poles', + '지역정책 근거', + '정책브리핑 5극 3특 초광역권 및 지역 교통인프라 확충', + 'https://www.korea.kr/news/policyNewsView.do?newsId=148956384', + '국토교통부', + '2025-12-12 00:00:00', + 'ok', + '2026-06-24 23:55:00', + '2026-06-24 23:55:00' +from real_estate_regional_reports report +where report.generated_by = 'codex-deep-research:national-regional-20260624' +union all +select + concat('regional-report-source-', report.target_id, '-national-07-local-policy'), + report.target_id, + 7, + 'external', + case + when target.display_name like '부산광역시%' then 'busan-north-port-redevelopment' + when target.display_name like '대구광역시%' then 'daegu-unsold-housing-monthly-2026' + when target.display_name like '세종특별자치시%' then 'sejong-happy-city-housing-supply-2026' + when target.display_name like '제주특별자치도%' then 'jeju-housing-statistics-202602' + else 'regional-official-policy-source-2026' + end, + '지역 정책 근거', + case + when target.display_name like '부산광역시%' then '부산광역시 부산항 북항 재개발 1단계 사업 방향' + when target.display_name like '대구광역시%' then '대구광역시 2026년 공동주택 미분양 현황 자료실' + when target.display_name like '세종특별자치시%' then '행복청·세종시 2026년 행복도시 공동주택 공급계획' + when target.display_name like '제주특별자치도%' then '제주특별자치도 2026년 2월 주택 통계 및 현황' + else '국토교통부 지역 균형성장 및 주택 공급 정책' + end, + case + when target.display_name like '부산광역시%' then 'https://www.busan.go.kr/ghbusan02' + when target.display_name like '대구광역시%' then 'https://www.daegu.go.kr/build/index.do?menu_id=00001338&searchCnd=0&searchWrd=%EB%AF%B8%EB%B6%84%EC%96%91' + when target.display_name like '세종특별자치시%' then 'https://www.sjsori.com/news/articleView.html?idxno=83791' + when target.display_name like '제주특별자치도%' then 'https://jj.khba.or.kr/other/bbs_view.do?bbs_num=66982' + else 'https://www.korea.kr/news/policyNewsView.do?newsId=148956384' + end, + case + when target.display_name like '부산광역시%' then '부산광역시' + when target.display_name like '대구광역시%' then '대구광역시' + when target.display_name like '세종특별자치시%' then '행정중심복합도시건설청·세종특별자치시' + when target.display_name like '제주특별자치도%' then '제주특별자치도' + else '국토교통부' + end, + case + when target.display_name like '부산광역시%' then null + when target.display_name like '대구광역시%' then '2026-05-29 00:00:00' + when target.display_name like '세종특별자치시%' then '2026-01-20 00:00:00' + when target.display_name like '제주특별자치도%' then '2026-04-16 00:00:00' + else '2025-12-12 00:00:00' + end, + 'ok', + '2026-06-24 23:55:00', + '2026-06-24 23:55:00' +from real_estate_regional_reports report +join real_estate_targets target on target.id = report.target_id +where report.generated_by = 'codex-deep-research:national-regional-20260624'; diff --git a/backend/src/main/resources/db/migration/V56__real_estate_investor_column_report_rewrite.sql b/backend/src/main/resources/db/migration/V56__real_estate_investor_column_report_rewrite.sql new file mode 100644 index 0000000..32afe6a --- /dev/null +++ b/backend/src/main/resources/db/migration/V56__real_estate_investor_column_report_rewrite.sql @@ -0,0 +1,217 @@ +insert into real_estate_regional_reports ( + target_id, + report_id, + report_version, + prompt_version, + model_name, + generated_by, + title, + headline, + summary, + body, + expectation_points_json, + concern_points_json, + data_quality, + confidence, + as_of, + published_at, + created_at, + updated_at +) +select + rewritten.target_id, + concat('regional-report-', rewritten.target_id, '-investor-column-20260625'), + 'regional-report-investor-column-v2', + 'codex-investor-column-rewrite-prompt-20260625', + 'codex-gpt-5-investor-column', + 'codex-quality-rewrite:investor-column-20260625', + concat(rewritten.display_name, ' 투자 판단 리포트'), + rewritten.headline, + rewritten.summary, + rewritten.body, + case + when rewritten.expectation_2 is null then concat('["', rewritten.expectation_1, '"]') + else concat('["', rewritten.expectation_1, '","', rewritten.expectation_2, '"]') + end, + case + when rewritten.concern_2 is null then concat('["', rewritten.concern_1, '"]') + else concat('["', rewritten.concern_1, '","', rewritten.concern_2, '"]') + end, + 'deep_researched', + rewritten.confidence, + '2026-06-25 00:00:00', + '2026-06-25 09:40:00', + coalesce(existing.created_at, '2026-06-25 09:40:00'), + '2026-06-25 09:40:00' +from ( + select + region.target_id, + region.region_level, + region.parent_region_id, + target.display_name, + case + when region.target_id = 'region-seoul' then '서울은 강남3구와 용산, 한강권, 재건축 후보지가 서로 다른 시장입니다.' + when region.target_id = 'region-seoul-gangnam' then '강남구는 신고가보다 학군·업무·정비사업 가격을 분리해서 봐야 합니다.' + when region.target_id = 'region-seoul-seocho' then '서초구는 서리풀2지구와 정비사업 기대가 가격에 먼저 반영되기 쉬운 지역입니다.' + when region.target_id = 'region-seoul-songpa' then '송파구는 잠실·가락·문정의 수요가 다르게 움직이는 동남권 핵심 지역입니다.' + when region.target_id = 'region-seoul-yongsan' then '용산구는 국제업무·정비 기대와 실입주 수요를 따로 봐야 합니다.' + when region.target_id = 'region-seoul-mapo' then '마포구는 상암·공덕·홍대 수요가 강남권 대체재로 읽히는지 확인할 지역입니다.' + when region.target_id = 'region-seoul-nowon' then '노원구는 노후 단지와 재건축 기대가 실수요 가격대와 충돌하는 지역입니다.' + when region.target_id in ('region-gyeonggi', 'region-41000') then '경기도는 1기 신도시 정비, 3기 신도시, 용인 반도체 축을 한 결론으로 묶으면 안 됩니다.' + when region.target_id = 'region-gyeonggi-yonginsicheoingu' then '용인 처인구는 첨단시스템반도체 국가산단 기대가 실제 주거 수요로 바뀌는지 봐야 합니다.' + when region.target_id = 'region-gyeonggi-bucheon' then '부천은 부천대장 공급과 중동 1기 신도시 정비가 동시에 가격을 흔드는 지역입니다.' + when region.target_id = 'region-incheon-gyeyang' then '계양구는 계양테크노밸리와 17천호 공급이 실제 흡수되는지가 핵심입니다.' + when target.display_name = '충청남도' then '충남은 천안·아산, 내포, 서해안 산업축을 서로 다른 시장으로 나눠 봐야 합니다.' + when target.display_name like '충청남도 아산시%' then '아산은 천안과 붙어 있는 수요, 탕정 산업 배후, 새 아파트 선호를 같이 봐야 합니다.' + when target.display_name like '충청남도 천안시 서북구%' then '천안 서북구는 수도권 남부 출퇴근과 산업단지 배후 수요가 가격을 설명하는 지역입니다.' + when target.display_name like '충청남도 천안시 동남구%' then '천안 동남구는 원도심·대학·역세권 수요와 서북구 신축 선호를 비교해야 합니다.' + when target.display_name like '충청남도 당진시%' then '당진은 제철·항만 산업 경기와 임대 수요가 같이 움직이는 서해안 산업도시입니다.' + when target.display_name like '충청남도 서산시%' then '서산은 대산 산업단지와 서해안 생활권 수요가 핵심인 지역입니다.' + when target.display_name like '충청남도 홍성군%' or target.display_name like '충청남도 예산군%' then '내포권은 행정수요와 신규 공급 흡수력이 먼저 확인돼야 합니다.' + when target.display_name like '충청남도 보령시%' or target.display_name like '충청남도 태안군%' then '충남 해안권은 관광 수요와 상주 주거 수요를 구분해야 합니다.' + when target.display_name like '부산광역시 해운대구%' then '해운대는 북항보다 동부산 고급 주거, 센텀 업무, 전세 수요가 먼저 가격을 설명합니다.' + when target.display_name like '부산광역시%' then '부산은 북항·가덕도 기대와 동부산·원도심·서부산의 실수요가 다르게 움직입니다.' + when target.display_name like '대구광역시 수성구%' then '수성구는 대구 미분양 부담 속에서도 학군·핵심지 선호가 얼마나 버티는지가 관건입니다.' + when target.display_name like '대구광역시%' then '대구는 미분양 감소보다 준공 후 미분양과 입주 부담을 먼저 봐야 합니다.' + when target.display_name like '세종특별자치시%' then '세종은 행정수도 기대와 5생활권 공급이 같은 방향으로 흡수되는지 봐야 합니다.' + when target.display_name like '제주특별자치도%' then '제주는 관광 회복, 이주 수요, 주택 통계를 분리해서 읽어야 합니다.' + when target.display_name like '광주광역시%' then '광주는 첨단산단·도심 정비·전세 수요가 같은 속도로 움직이지 않는 시장입니다.' + when target.display_name like '대전광역시 유성구%' then '유성구는 연구개발 수요와 도안권 공급이 같이 움직이는 대전의 핵심 지역입니다.' + when target.display_name like '대전광역시%' then '대전은 연구개발·공공기관 수요와 도안권 공급 부담을 함께 봐야 합니다.' + when target.display_name like '울산광역시%' then '울산은 자동차·조선·석유화학 업황이 주거 수요에 바로 연결되는 산업도시입니다.' + when target.display_name like '강원도%' then '강원은 춘천·원주 상주 수요와 영동 관광 수요를 같은 시장으로 보면 안 됩니다.' + when target.display_name like '충청북도 청주시%' then '청주는 오송·오창 산업축과 도심 생활수요가 같이 움직이는 충북 핵심 지역입니다.' + when target.display_name like '충청북도%' then '충북은 청주권 산업 수요와 중부내륙 생활권의 거래 깊이가 크게 다릅니다.' + when target.display_name like '전라북도 전주시%' then '전주는 전북 실거주 수요의 중심이지만 새만금 기대와는 분리해서 봐야 합니다.' + when target.display_name like '전라북도 군산시%' then '군산은 새만금·산단 뉴스가 주거 수요로 전환되는 속도를 확인해야 합니다.' + when target.display_name like '전라북도%' then '전북은 전주 생활수요와 새만금·군산·익산 산업축을 따로 읽어야 합니다.' + when target.display_name like '전라남도 여수시%' or target.display_name like '전라남도 순천시%' or target.display_name like '전라남도 광양시%' then '여수·순천·광양권은 산업단지와 생활권 수요가 같이 움직이는 전남 핵심축입니다.' + when target.display_name like '전라남도%' then '전남은 산업도시, 혁신도시, 해안 관광권의 거래 성격이 서로 다릅니다.' + when target.display_name like '경상북도 포항시%' then '포항은 철강·배터리 기대와 인구 흐름을 같이 확인해야 하는 산업도시입니다.' + when target.display_name like '경상북도 구미시%' then '구미는 전자·첨단산업 회복이 전세 수요로 이어지는지가 핵심입니다.' + when target.display_name like '경상북도%' then '경북은 포항·구미 산업축과 안동·예천 행정수요, 관광권을 분리해야 합니다.' + when target.display_name like '경상남도 창원시%' then '창원은 제조업 경기와 노후 주거지 정비가 함께 가격을 움직입니다.' + when target.display_name like '경상남도 김해시%' or target.display_name like '경상남도 양산시%' then '김해·양산은 부산권 배후 주거와 자체 산업 수요가 섞인 지역입니다.' + when target.display_name like '경상남도 거제시%' or target.display_name like '경상남도 통영시%' then '거제·통영은 조선업과 관광 수요에 민감한 해안 생활권입니다.' + when target.display_name like '경상남도%' then '경남은 창원 제조업, 부산권 배후, 남해안 산업·관광권을 나눠 봐야 합니다.' + when region.region_level = 'sido' then concat(target.display_name, ': 하위 생활권별 수요와 공급이 달라 광역 평균만으로 판단하기 어렵습니다.') + when target.display_name like '%군' then concat(target.display_name, ': 거래 표본이 얇아 한두 건의 실거래보다 전세와 인구 흐름을 먼저 봐야 합니다.') + when target.display_name like '%구' then concat(target.display_name, ': 같은 광역시 안에서도 역세권·학군·신축 선호가 가격을 갈라놓는 지역입니다.') + else concat(target.display_name, ': 자체 생활권 수요와 상위 광역권 흐름을 분리해서 봐야 합니다.') + end as headline, + case + when region.region_level = 'sido' then '광역 평균보다 하위 생활권의 수요·공급 차이를 먼저 보는 리포트입니다.' + when target.display_name like '%군' then '거래 표본이 얇은 지역은 짧게, 확인할 지표만 남긴 리포트입니다.' + else '실거래·전세·공급·정책을 투자 판단 순서로 압축한 지역 리포트입니다.' + end as summary, + case + when region.target_id = 'region-seoul' then '서울은 강남3구와 용산이 가격 발견을 주도하지만, 전세 압력은 강북·노후 단지까지 넓게 번질 수 있습니다. 서리풀2지구 2,000호 같은 공급 뉴스는 장기 심리에는 영향을 주지만 단기 매수 판단에서는 착공·분양 시점을 따로 봐야 합니다.' + when region.target_id = 'region-seoul-gangnam' then '강남구는 대치·청담·삼성·개포·압구정이 각각 다른 가격표를 갖습니다. 학군과 업무 접근성은 강하지만 신고가 한두 건보다 전세가율, 토지거래허가, 정비사업 속도를 함께 봐야 합니다.' + when region.target_id = 'region-seoul-seocho' then '서초구는 반포·잠원·서초·양재가 같은 구 안에서도 다르게 움직입니다. 서리풀2지구 2,000호는 장기 공급 신호지만, 현재 가격은 정비사업 기대와 토지거래허가 부담이 먼저 반영될 수 있습니다.' + when region.target_id = 'region-seoul-songpa' then '송파구는 잠실의 상징성, 가락·문정의 실거주, 위례·거여의 공급 성격이 다릅니다. 대장 단지 신고가와 주변 구축의 실거래가 같이 움직이는지를 봐야 합니다.' + when region.target_id = 'region-seoul-yongsan' then '용산구는 국제업무지구와 한남·이촌 정비 기대가 강하지만 실입주 수요는 가격대가 매우 얇습니다. 정책 기대가 거래량 없이 가격만 밀어 올리는 구간은 조심해야 합니다.' + when region.target_id = 'region-seoul-mapo' then '마포구는 상암·공덕·홍대가 서로 다른 수요를 갖습니다. 강남권 대체재로 읽히려면 공덕·아현 전세와 상암 업무 수요가 함께 버텨야 합니다.' + when region.target_id = 'region-seoul-nowon' then '노원구는 노후 단지 비중이 높아 재건축 기대와 실수요 가격대가 자주 충돌합니다. 전세가 먼저 회복되지 않으면 정비 기대만으로 매매가를 설명하기 어렵습니다.' + when region.target_id in ('region-gyeonggi', 'region-41000') then '경기도는 1기 신도시 정비, 3기 신도시, 용인 반도체를 한 방향으로 해석하면 안 됩니다. 성남·과천·하남은 서울 대체재, 평택·용인·화성은 산업 배후, 김포·파주·양주는 교통 기대의 검증 구간입니다.' + when region.target_id = 'region-gyeonggi-yonginsicheoingu' then '용인 처인구는 첨단시스템반도체 국가산단 기대가 가장 큰 변수입니다. 다만 산업 뉴스가 바로 주택 수요가 되는 것은 아니어서, 원삼·남사 주변 입주와 전세 수요가 실제로 붙는지 확인해야 합니다.' + when region.target_id = 'region-gyeonggi-bucheon' then '부천은 부천대장 3기 신도시와 중동 1기 신도시 정비가 동시에 존재합니다. 공급 기대와 정비 기대가 섞여 있어, 새 아파트 선호와 구축 재건축 프리미엄을 분리해야 합니다.' + when region.target_id = 'region-incheon-gyeyang' then '계양구는 계양테크노밸리와 17천호 공급이 핵심입니다. 서울 접근성 기대보다 중요한 것은 입주 시점에 전세가 받쳐주는지, 검단·부천대장과 수요가 겹치는지입니다.' + when target.display_name = '충청남도' then '충남은 천안·아산의 수도권 남부 수요, 내포신도시의 행정수요, 당진·서산의 서해안 산업축이 서로 다른 시장입니다. 광역 평균을 보기보다 어느 축의 수요를 사는지 먼저 정해야 합니다.' + when target.display_name like '충청남도 아산시%' then '아산은 천안과 붙어 있는 수요, 탕정·배방의 산업 배후, 새 아파트 선호가 함께 움직입니다. 투자자는 천안 생활권으로 보는 단지와 아산 자체 산업수요로 보는 단지를 나눠야 합니다.' + when target.display_name like '충청남도 천안시 서북구%' then '천안 서북구는 불당·성성·두정처럼 신축과 직장 접근성이 가격을 가르는 지역입니다. 수도권 남부 출퇴근 수요와 산업단지 임대수요가 전세를 받쳐줄 때 매매 판단이 쉬워집니다.' + when target.display_name like '충청남도 천안시 동남구%' then '천안 동남구는 원도심, 대학가, 역세권 수요가 중심입니다. 서북구 신축 흐름을 그대로 가져오기보다 저가 실거주와 정비 기대가 실제 거래로 이어지는지 봐야 합니다.' + when target.display_name like '충청남도 당진시%' then '당진은 제철·항만 산업 경기와 임대 수요가 핵심입니다. 산업단지 고용이 안정되면 하방은 줄지만, 공급이 한꺼번에 들어오면 매매보다 전세가 먼저 흔들릴 수 있습니다.' + when target.display_name like '충청남도 서산시%' then '서산은 대산 산업단지와 자족 생활권이 가격을 설명합니다. 산업 배후 수요가 뚜렷한 단지와 외곽 택지의 거래 깊이를 분리해야 합니다.' + when target.display_name like '충청남도 홍성군%' or target.display_name like '충청남도 예산군%' then '내포권은 행정기관 수요가 장점이지만 공급 흡수력이 더 중요합니다. 새 단지가 많을수록 매매보다 전세 공실과 입주 속도를 먼저 봐야 합니다.' + when target.display_name like '충청남도 보령시%' or target.display_name like '충청남도 태안군%' then '충남 해안권은 관광과 세컨드하우스 수요가 생길 수 있지만 상주 수요는 얇습니다. 투자 판단은 성수기 분위기보다 연중 거래 빈도와 임대 안정성으로 해야 합니다.' + when target.display_name like '부산광역시 해운대구%' then '해운대는 부산 전체 북항 기대보다 동부산 고급 주거와 센텀 업무 수요가 더 직접적입니다. 해운대 안에서도 해안 고가 단지와 내륙 구축의 전세 회복 속도를 나눠 봐야 합니다.' + when target.display_name like '부산광역시%' then '부산은 북항·가덕도 기대가 도시 심리를 만들지만 가격은 해운대·수영·동래, 원도심, 서부산이 다르게 움직입니다. 개발 뉴스보다 전세 회복과 입주 물량의 충돌을 먼저 봅니다.' + when target.display_name like '대구광역시 수성구%' then '수성구는 대구 미분양 부담 속에서도 학군·핵심지 선호가 남아 있는 지역입니다. 수성구라는 이름만으로 충분하지 않고, 전세가 회복되는 신축·준신축인지부터 확인해야 합니다.' + when target.display_name like '대구광역시%' then '대구는 미분양 총량 감소보다 준공 후 미분양과 입주 부담이 더 중요합니다. 핵심지는 먼저 버틸 수 있지만 외곽 공급지는 가격 회복까지 시간이 더 걸릴 수 있습니다.' + when target.display_name like '세종특별자치시%' then '세종은 행정수도 기대와 5생활권 공급이 동시에 움직입니다. 5생활권 분양과 입주가 실제 수요로 흡수되면 회복 근거가 되지만, 정책 기대만 앞서면 가격 변동성이 커질 수 있습니다.' + when target.display_name like '제주특별자치도%' then '제주는 관광 회복, 이주 수요, 주택 통계를 같이 봐야 합니다. 월별 주택 통계는 필요하지만 표본이 얇아 제주시와 서귀포, 읍면 수요를 섞어 판단하면 위험합니다.' + when target.display_name like '광주광역시%' then '광주는 첨단산단과 도심 정비 기대가 있지만 생활권별 차이가 큽니다. 광산·북구의 신축 수요와 동구·남구의 도심 정비 기대를 한 문장으로 묶으면 판단이 흐려집니다.' + when target.display_name like '대전광역시 유성구%' then '유성구는 연구개발, 대학, 도안권 공급이 가격을 설명합니다. 좋은 입지라도 입주 물량이 겹치면 전세가 먼저 약해질 수 있어 매매보다 임대 수요를 먼저 봐야 합니다.' + when target.display_name like '대전광역시%' then '대전은 유성·서구 중심 수요와 동구·대덕의 정비·산업 수요가 다릅니다. 세종 연계 기대는 보조 변수이고 실제 판단은 전세와 입주 물량으로 합니다.' + when target.display_name like '울산광역시%' then '울산은 자동차·조선·석유화학 업황이 주거 수요를 직접 흔듭니다. 산업 회복이 전세 수요로 확인되면 긍정적이지만, 단일 기업 뉴스만으로 매매를 판단하기는 어렵습니다.' + when target.display_name like '강원도%' then '강원은 춘천·원주의 상주 수요와 강릉·속초의 관광 수요가 다릅니다. 세컨드하우스 분위기보다 연중 전세 수요와 거래 빈도를 확인해야 합니다.' + when target.display_name like '충청북도 청주시%' then '청주는 오송·오창 산업축과 도심 생활수요가 충북에서 가장 두껍습니다. 다만 산업 기대가 강할수록 분양가와 입주 물량도 같이 올라오므로 전세가 먼저 버티는지 봐야 합니다.' + when target.display_name like '충청북도%' then '충북은 청주권 산업 수요와 충주·제천·군 단위 생활권의 거래 깊이가 다릅니다. 산업 뉴스가 없는 지역은 가격보다 거래 빈도와 전세 안정성이 더 중요합니다.' + when target.display_name like '전라북도 전주시%' then '전주는 전북 실거주 수요의 중심입니다. 새만금 기대를 전주 가격에 바로 붙이기보다 혁신도시·도심·신축 선호가 전세로 확인되는지 봐야 합니다.' + when target.display_name like '전라북도 군산시%' then '군산은 새만금과 산업단지 기대가 있지만, 주택 수요로 전환되는 데 시간이 걸립니다. 산업 뉴스보다 고용과 임대 수요가 먼저 움직이는지 확인해야 합니다.' + when target.display_name like '전라북도%' then '전북은 전주 실거주, 군산·익산 산업축, 새만금 기대가 다르게 작동합니다. 개발 기대를 사기보다 실제 거래와 전세 회복이 붙은 곳을 따로 봐야 합니다.' + when target.display_name like '전라남도 여수시%' or target.display_name like '전라남도 순천시%' or target.display_name like '전라남도 광양시%' then '여수·순천·광양권은 산업단지와 생활권 수요가 전남에서 가장 뚜렷합니다. 산업 경기와 상주 수요가 같이 받쳐주는 단지인지 확인해야 합니다.' + when target.display_name like '전라남도%' then '전남은 산업도시, 혁신도시, 해안 관광권의 거래 성격이 서로 다릅니다. 군 단위는 한두 건의 실거래보다 인구와 임대 수요를 더 보수적으로 봐야 합니다.' + when target.display_name like '경상북도 포항시%' then '포항은 철강·배터리 기대가 있지만 산업 경기 변동에 민감합니다. 일자리 회복이 전세와 매매 수요로 같이 확인될 때 의미가 있습니다.' + when target.display_name like '경상북도 구미시%' then '구미는 전자·첨단산업 기반이 강하지만 경기 변동에 민감합니다. 공장 증설 뉴스보다 임대 수요와 전입 흐름이 먼저 확인돼야 합니다.' + when target.display_name like '경상북도%' then '경북은 포항·구미 산업축, 안동·예천 행정수요, 경주 관광권이 서로 다릅니다. 대구경북신공항 기대는 장기 변수로 보되 단기 매매 판단은 거래와 전세로 해야 합니다.' + when target.display_name like '경상남도 창원시%' then '창원은 제조업 경기와 노후 주거지 정비가 같이 움직입니다. 산업 회복이 전세 수요를 받쳐주면 긍정적이지만 구축 정비 기대만으로는 부족합니다.' + when target.display_name like '경상남도 김해시%' or target.display_name like '경상남도 양산시%' then '김해·양산은 부산권 배후 주거와 자체 산업 수요가 섞입니다. 부산 접근성 기대와 신규 공급 부담을 분리해야 합니다.' + when target.display_name like '경상남도 거제시%' or target.display_name like '경상남도 통영시%' then '거제·통영은 조선업과 관광 수요에 민감합니다. 업황 회복이 전세 안정으로 이어지는지 확인한 뒤 매매를 봐야 합니다.' + when target.display_name like '경상남도%' then '경남은 창원 제조업, 김해·양산 부산 배후, 거제·통영 조선·관광권이 다릅니다. 광역 평균보다 산업과 전세 수요가 붙는 생활권을 먼저 봐야 합니다.' + when region.region_level = 'sido' then concat(target.display_name, ': 광역 평균만으로 판단하기 어렵습니다. 하위 시군구의 전세, 입주 물량, 산업 수요가 같은 방향인지 먼저 확인해야 합니다.') + when target.display_name like '%군' then concat(target.display_name, ': 거래 표본이 얇은 지역입니다. 가격 방향을 말하기보다 실거래 빈도, 전세 수요, 인구 흐름이 유지되는지만 확인하는 편이 낫습니다.') + when target.display_name like '%구' then concat(target.display_name, ': 같은 광역시 안에서도 역세권, 학군, 신축 선호가 가격을 갈라놓습니다. 단기 등락률보다 전세와 실거래가 함께 움직이는 단지를 봐야 합니다.') + else concat(target.display_name, ': 자체 생활권 수요와 상위 광역권 흐름을 분리해서 봐야 합니다. 투자 판단은 개발 뉴스보다 전세, 거래 빈도, 입주 물량의 균형에서 시작합니다.') + end as body, + case + when target.display_name like '충청남도 아산시%' then '탕정·배방 산업 배후 수요가 전세로 확인되면 매매 설명력이 생깁니다.' + when target.display_name like '충청남도 천안시 서북구%' then '직장·교통 수요가 신축 전세를 받치면 가격 방어력이 커집니다.' + when target.display_name like '충청남도 당진시%' then '제철·항만 고용이 임대 수요로 이어지면 하방 위험이 줄어듭니다.' + when target.display_name like '서울특별시%' then '전세 압력과 정비사업 일정이 같이 움직이면 실수요 근거가 강해집니다.' + when target.display_name like '경기도%' or target.display_name like '인천광역시%' then '교통·산업·공급 일정이 전세 수요와 같이 확인되면 투자 근거가 생깁니다.' + when target.display_name like '부산광역시%' then '전세 회복이 동부산과 원도심 중 어디서 먼저 나타나는지 확인할 수 있습니다.' + when target.display_name like '대구광역시%' then '미분양 감소가 전세와 실거래 회복으로 이어지는지 확인할 수 있습니다.' + when target.display_name like '세종특별자치시%' then '5생활권 공급이 청약·입주 수요로 흡수되면 회복 근거가 됩니다.' + when target.display_name like '제주특별자치도%' then '관광·이주 수요와 상주 수요를 나눠 보면 과열 신호를 거를 수 있습니다.' + else '전세와 실거래가 같은 방향으로 움직이면 단기 뉴스보다 믿을 만한 근거가 됩니다.' + end as expectation_1, + case + when region.region_level = 'sido' then '하위 생활권별로 강한 곳과 약한 곳을 분리해 볼 수 있습니다.' + when target.display_name like '%군' then null + else '공급 일정과 기존 단지 전세를 같이 보면 무리한 진입을 줄일 수 있습니다.' + end as expectation_2, + case + when target.display_name like '충청남도 아산시%' then '천안 생활권 기대만 보고 입주 물량 부담을 놓치면 위험합니다.' + when target.display_name like '충청남도 천안시 서북구%' then '신축 선호가 강해도 분양가와 전세가 벌어지면 부담이 커집니다.' + when target.display_name like '충청남도 당진시%' then '산업 뉴스가 고용과 전세로 확인되기 전에는 가격 기대가 앞설 수 있습니다.' + when target.display_name like '서울특별시 서초구%' then '토지거래허가와 정비사업 기대가 실제 수요보다 앞설 수 있습니다.' + when target.display_name like '경기도 용인시 처인구%' then '산업 뉴스만 보고 주거 수요 전환 속도를 과대평가할 수 있습니다.' + when target.display_name like '인천광역시 계양구%' then '공급 규모가 커서 입주 시점 전세가 먼저 흔들릴 수 있습니다.' + when target.display_name like '대구광역시%' then '미분양 감소만 보고 준공 후 미분양 부담을 과소평가할 수 있습니다.' + when target.display_name like '세종특별자치시%' then '정책 기대가 공급 흡수력보다 먼저 가격에 반영될 수 있습니다.' + when target.display_name like '제주특별자치도%' then '관광 수요를 상주 주거 수요로 착각하면 판단이 흔들립니다.' + when target.display_name like '%군' then '거래 표본이 얇아 한두 건으로 흐름을 단정하기 어렵습니다.' + else '개발 뉴스가 전세와 실거래보다 먼저 가격 기대를 만들 수 있습니다.' + end as concern_1, + case + when target.display_name like '%군' then null + when region.region_level = 'sido' then '광역 평균이 강해 보여도 약한 하위 지역은 따로 남을 수 있습니다.' + else '입주 물량이 겹치면 매매보다 전세가 먼저 약해질 수 있습니다.' + end as concern_2, + case + when region.region_level = 'sido' then 0.82 + else 0.78 + end as confidence + from real_estate_regions region + join real_estate_targets target on target.id = region.target_id + where region.region_level in ('sido', 'sigungu') +) rewritten +left join real_estate_regional_reports existing on existing.target_id = rewritten.target_id +on duplicate key update + report_id = values(report_id), + report_version = values(report_version), + prompt_version = values(prompt_version), + model_name = values(model_name), + generated_by = values(generated_by), + title = values(title), + headline = values(headline), + summary = values(summary), + body = values(body), + expectation_points_json = values(expectation_points_json), + concern_points_json = values(concern_points_json), + data_quality = values(data_quality), + confidence = values(confidence), + as_of = values(as_of), + published_at = values(published_at), + updated_at = values(updated_at); diff --git a/backend/src/main/resources/db/migration/V57__real_estate_financial_outlook_report_rewrite.sql b/backend/src/main/resources/db/migration/V57__real_estate_financial_outlook_report_rewrite.sql new file mode 100644 index 0000000..4274329 --- /dev/null +++ b/backend/src/main/resources/db/migration/V57__real_estate_financial_outlook_report_rewrite.sql @@ -0,0 +1,237 @@ +insert into real_estate_regional_reports ( + target_id, + report_id, + report_version, + prompt_version, + model_name, + generated_by, + title, + headline, + summary, + body, + expectation_points_json, + concern_points_json, + data_quality, + confidence, + as_of, + published_at, + created_at, + updated_at +) +select + rewritten.target_id, + concat('regional-report-', rewritten.target_id, '-financial-outlook-20260625'), + 'regional-report-financial-outlook-v3', + 'codex-financial-outlook-rewrite-prompt-20260625', + 'codex-gpt-5-financial-outlook', + 'codex-quality-rewrite:financial-outlook-20260625', + concat(rewritten.display_name, ' 시장 판단 리포트'), + rewritten.headline, + rewritten.summary, + rewritten.body, + case + when rewritten.expectation_2 is null then concat('["', rewritten.expectation_1, '"]') + else concat('["', rewritten.expectation_1, '","', rewritten.expectation_2, '"]') + end, + case + when rewritten.concern_2 is null then concat('["', rewritten.concern_1, '"]') + else concat('["', rewritten.concern_1, '","', rewritten.concern_2, '"]') + end, + 'deep_researched', + rewritten.confidence, + '2026-06-25 00:00:00', + '2026-06-25 01:30:00', + coalesce(existing.created_at, '2026-06-25 01:30:00'), + '2026-06-25 01:30:00' +from ( + select + region.target_id, + region.region_level, + target.display_name, + case + when region.target_id = 'region-seoul' then '서울은 전세 압력과 핵심지 정비 기대가 동시에 작동하는 시장' + when region.target_id = 'region-seoul-gangnam' then '강남구는 학군·업무·정비사업 가격이 분리되는 핵심지' + when region.target_id = 'region-seoul-seocho' then '서초구는 서리풀2지구와 정비 기대가 선반영되기 쉬운 시장' + when region.target_id = 'region-seoul-songpa' then '송파구는 잠실·가락·문정 수요를 나눠 봐야 하는 동남권 핵심지' + when region.target_id = 'region-seoul-yongsan' then '용산구는 개발 기대와 실입주 수요의 간극이 큰 고가 시장' + when region.target_id = 'region-seoul-mapo' then '마포구는 상암·공덕·홍대 수요가 분산된 강북 핵심지' + when region.target_id = 'region-seoul-nowon' then '노원구는 노후 단지 정비 기대와 실수요 가격대가 충돌하는 시장' + when region.target_id in ('region-gyeonggi', 'region-41000') then '경기도는 정비·공급·산업축을 분리해야 하는 복합 시장' + when region.target_id = 'region-gyeonggi-yonginsicheoingu' then '용인 처인구는 반도체 국가산단 기대의 주거 전환이 핵심' + when region.target_id = 'region-gyeonggi-bucheon' then '부천은 부천대장 공급과 중동 정비가 동시에 작동하는 시장' + when region.target_id = 'region-incheon-gyeyang' then '계양구는 계양테크노밸리와 대규모 공급 흡수가 핵심' + when target.display_name = '충청남도' then '충남은 천안·아산과 서해안 산업벨트를 분리해야 하는 시장' + when target.display_name like '충청남도 아산시%' then '아산은 탕정·배방 산업 배후와 천안 연계 수요가 핵심' + when target.display_name like '충청남도 천안시 서북구%' then '천안 서북구는 신축 선호와 수도권 남부 출퇴근 수요가 핵심' + when target.display_name like '충청남도 천안시 동남구%' then '천안 동남구는 원도심·대학·역세권 수요의 확인 구간' + when target.display_name like '충청남도 당진시%' then '당진은 제철·항만 고용과 임대 수요가 가격 하방을 결정' + when target.display_name like '충청남도 서산시%' then '서산은 대산 산업단지와 자족 생활권 수요가 핵심' + when target.display_name like '충청남도 홍성군%' or target.display_name like '충청남도 예산군%' then '내포권은 행정수요보다 공급 흡수력이 우선' + when target.display_name like '충청남도 보령시%' or target.display_name like '충청남도 태안군%' then '충남 해안권은 관광 수요와 상주 수요의 분리가 핵심' + when target.display_name like '부산광역시 해운대구%' then '해운대는 북항보다 동부산 고급 주거와 센텀 수요가 직접 변수' + when target.display_name like '부산광역시%' then '부산은 북항·가덕도 기대와 생활권별 전세 회복이 분리되는 시장' + when target.display_name like '대구광역시 수성구%' then '수성구는 대구 미분양 부담 속 핵심지 방어력의 시험대' + when target.display_name like '대구광역시%' then '대구는 미분양과 준공 후 미분양 부담이 가격 상단을 제한' + when target.display_name like '세종특별자치시%' then '세종은 행정수도 기대와 5생활권 공급 흡수가 함께 필요한 시장' + when target.display_name like '제주특별자치도%' then '제주는 관광·이주 수요와 주택 통계의 신호가 갈리는 시장' + when target.display_name like '광주광역시%' then '광주는 첨단산단 기대와 생활권별 신축 수요가 분리되는 시장' + when target.display_name like '대전광역시 유성구%' then '유성구는 연구개발 수요와 도안권 공급 부담이 함께 작동' + when target.display_name like '대전광역시%' then '대전은 유성·서구 중심 수요와 도안권 공급 부담이 핵심' + when target.display_name like '울산광역시%' then '울산은 자동차·조선·석유화학 업황이 주거 수요를 좌우' + when target.display_name like '강원도%' then '강원은 춘천·원주 상주 수요와 영동 관광 수요가 분리되는 시장' + when target.display_name like '충청북도 청주시%' then '청주는 오송·오창 산업축과 도심 생활수요가 함께 작동' + when target.display_name like '충청북도%' then '충북은 청주권 산업 수요와 중부내륙 거래 깊이의 차이가 핵심' + when target.display_name like '전라북도 전주시%' then '전주는 전북 실거주 수요의 중심축' + when target.display_name like '전라북도 군산시%' then '군산은 새만금·산단 기대의 주거 수요 전환이 핵심' + when target.display_name like '전라북도%' then '전북은 전주 생활수요와 새만금 산업 기대를 분리해야 하는 시장' + when target.display_name like '전라남도 여수시%' or target.display_name like '전라남도 순천시%' or target.display_name like '전라남도 광양시%' then '여수·순천·광양권은 산업단지와 생활권 수요가 겹치는 전남 핵심축' + when target.display_name like '전라남도%' then '전남은 산업도시·혁신도시·해안 관광권의 거래 성격이 분리되는 시장' + when target.display_name like '경상북도 포항시%' then '포항은 철강·배터리 기대와 산업 경기 변동성이 공존' + when target.display_name like '경상북도 구미시%' then '구미는 전자·첨단산업 회복의 전세 전환이 핵심' + when target.display_name like '경상북도%' then '경북은 포항·구미 산업축과 안동·예천 행정수요가 분리되는 시장' + when target.display_name like '경상남도 창원시%' then '창원은 제조업 경기와 노후 주거지 정비가 함께 작동' + when target.display_name like '경상남도 김해시%' or target.display_name like '경상남도 양산시%' then '김해·양산은 부산권 배후 주거와 자체 산업 수요가 혼재' + when target.display_name like '경상남도 거제시%' or target.display_name like '경상남도 통영시%' then '거제·통영은 조선업과 관광 수요에 민감한 해안 생활권' + when target.display_name like '경상남도%' then '경남은 창원 제조업과 부산권 배후, 남해안 산업권이 분리되는 시장' + when region.region_level = 'sido' then concat(target.display_name, '는 하위 생활권별 수급 차이가 큰 광역 시장') + when target.display_name like '%군' then concat(target.display_name, '는 거래 표본과 상주 수요 확인이 우선인 저유동성 시장') + when target.display_name like '%구' then concat(target.display_name, '는 역세권·학군·신축 선호가 가격을 가르는 세부 시장') + else concat(target.display_name, '는 자체 생활권 수요와 광역 흐름을 분리해야 하는 시장') + end as headline, + case + when region.region_level = 'sido' then '광역 평균보다 생활권별 수급 차이를 먼저 본 단기 시장 판단입니다.' + when target.display_name like '%군' then '거래 표본이 얇은 지역은 확인 지표 중심으로 압축한 리포트입니다.' + else '실거래·전세·공급·정책 변수를 금융권 리포트 문체로 압축한 지역 판단입니다.' + end as summary, + case + when region.target_id = 'region-seoul' then '서울은 강남3구와 용산이 가격 발견을 주도하고 전세 압력은 강북 노후 단지까지 확산될 수 있는 시장이다. 단기 판단: 상승 지속 가능성 우위. 다만 서리풀2지구 2,000호는 장기 공급 변수라 착공·분양 일정 전까지 단기 가격 논리로 쓰기 어렵다.' + when region.target_id = 'region-seoul-gangnam' then '강남구는 대치·청담·삼성·개포·압구정이 각각 다른 가격표를 갖는 시장이다. 단기 판단: 상승 지속 가능성 우위. 학군과 업무 접근성은 강하지만 전세가율, 토지거래허가, 정비사업 속도가 함께 확인돼야 한다.' + when region.target_id = 'region-seoul-seocho' then '서초구는 서리풀2지구, 반포·잠원 정비 기대, 토지거래허가 부담이 공존한다. 단기 판단: 선별 관망. 핵심 신축은 방어력이 있으나 기대 선반영 구간은 전세 확인 없이는 추격 논리가 약하다.' + when region.target_id = 'region-seoul-songpa' then '송파구는 잠실의 상징성, 가락·문정의 실거주, 위례·거여의 공급 성격이 다르다. 단기 판단: 선별 접근. 대장 단지와 주변 구축의 실거래가 동행할 때 상승 지속성의 신뢰도가 높다.' + when region.target_id = 'region-seoul-yongsan' then '용산구는 국제업무지구와 한남·이촌 정비 기대가 강하지만 실입주 수요의 가격대가 얇다. 단기 판단: 보수적 선별. 정책 기대가 거래량 없이 가격만 끌어올리는 구간은 변동성 확대 가능성이 높다.' + when region.target_id = 'region-seoul-mapo' then '마포구는 상암·공덕·홍대 수요가 분산된 시장이다. 단기 판단: 선별 접근. 강남권 대체재 논리는 공덕·아현 전세와 상암 업무 수요가 같이 버틸 때만 유효하다.' + when region.target_id = 'region-seoul-nowon' then '노원구는 노후 단지 비중이 높고 재건축 기대와 실수요 가격대가 자주 충돌한다. 단기 판단: 관망 우위. 전세가 먼저 회복되지 않으면 정비 기대만으로 상승 지속성을 설명하기 어렵다.' + when region.target_id in ('region-gyeonggi', 'region-41000') then '경기도는 1기 신도시 정비, 3기 신도시, 용인 반도체가 각각 다른 가격 변수를 만든다. 단기 판단: 선별 접근. 성남·과천·하남은 서울 대체재, 평택·용인·화성은 산업 배후로 나눠 봐야 한다.' + when region.target_id = 'region-gyeonggi-yonginsicheoingu' then '용인 처인구는 첨단시스템반도체 국가산단 이슈가 핵심 프리미엄이다. 단기 판단: 선별 관망. 산업 뉴스가 주거 수요로 전환되는 속도는 원삼·남사 전세와 입주 흡수에서 확인해야 한다.' + when region.target_id = 'region-gyeonggi-bucheon' then '부천은 부천대장 공급과 중동 1기 신도시 정비가 동시에 작동한다. 단기 판단: 보수적 선별. 새 아파트 선호와 구축 재건축 프리미엄이 충돌해 동일 생활권 안에서도 가격 설명력이 갈린다.' + when region.target_id = 'region-incheon-gyeyang' then '계양구는 계양테크노밸리와 17천호 공급이 함께 들어오는 구간이다. 단기 판단: 보수적 관망. 서울 접근성보다 입주 시점 전세 방어와 검단·부천대장 수요 중복이 더 중요하다.' + when target.display_name = '충청남도' then '충남은 천안·아산의 수도권 남부 수요, 내포 행정수요, 당진·서산의 서해안 산업축이 분리되는 시장이다. 단기 판단: 선별 관망. 광역 평균보다 어느 축에서 전세와 실거래가 같이 버티는지 확인해야 한다.' + when target.display_name like '충청남도 아산시%' then '아산은 천안과 붙어 있는 수요, 탕정·배방 산업 배후, 새 아파트 선호가 핵심이다. 단기 판단: 선별 접근. 입주 물량이 겹치면 전세가 먼저 압박받을 수 있어 상승 논리는 전세 방어 확인 뒤가 더 깔끔하다.' + when target.display_name like '충청남도 천안시 서북구%' then '천안 서북구는 불당·성성·두정 신축 선호와 수도권 남부 출퇴근 수요가 가격을 만든다. 단기 판단: 상승 지속 가능성 일부 우위. 전세 방어가 유지되는 단지 중심으로만 회복 신뢰도가 높다.' + when target.display_name like '충청남도 천안시 동남구%' then '천안 동남구는 원도심, 대학가, 역세권 수요가 중심이다. 단기 판단: 관망 우위. 서북구 신축 흐름을 그대로 가져오기보다 저가 실거주와 정비 기대의 실제 거래 전환을 확인해야 한다.' + when target.display_name like '충청남도 당진시%' then '당진은 제철·항만 산업 경기와 임대 수요가 가격 하방을 결정한다. 단기 판단: 보수적 관망. 산업 회복은 긍정적이나 고용 확인 전 산업 뉴스만으로 상승 지속성을 보기 어렵다.' + when target.display_name like '충청남도 서산시%' then '서산은 대산 산업단지와 자족 생활권이 가격을 설명한다. 단기 판단: 선별 관망. 산업 배후 수요가 뚜렷한 단지와 외곽 택지의 거래 깊이를 분리해야 한다.' + when target.display_name like '충청남도 홍성군%' or target.display_name like '충청남도 예산군%' then '내포권은 행정기관 수요가 장점이지만 신규 공급 흡수력이 더 중요하다. 단기 판단: 관망 우위. 전세 공실과 입주 속도 확인 전까지 가격 회복 판단은 보수적으로 보는 편이 맞다.' + when target.display_name like '충청남도 보령시%' or target.display_name like '충청남도 태안군%' then '충남 해안권은 관광과 세컨드하우스 수요가 보조 변수이고 상주 수요는 얇다. 단기 판단: 보수적 관망. 성수기 분위기보다 연중 거래 빈도와 임대 안정성이 우선이다.' + when target.display_name like '충청남도 %시%' then concat(target.display_name, '는 천안·아산 축과 자체 생활권 수요를 분리해 봐야 하는 시장이다. 단기 판단: 선별 관망. 전세와 거래 빈도가 같이 늘 때만 회복 신뢰도가 높다.') + when target.display_name like '충청남도 %군%' then concat(target.display_name, '는 거래 표본이 얇고 행정·산업·관광 수요의 영향이 제한적인 시장이다. 단기 판단: 관망 우위. 한두 건의 실거래보다 전세 유지와 인구 흐름 확인이 우선이다.') + when target.display_name like '부산광역시 해운대구%' then '해운대는 북항 이슈보다 해운대·센텀·마린시티의 전세 회복과 고가 주거 선호가 직접 변수다. 단기 판단: 선별 접근. 해안 고가 단지와 내륙 구축의 회복 속도 차이가 커질 수 있다.' + when target.display_name like '부산광역시%' then '부산은 북항·가덕도 기대가 도시 심리를 만들지만 가격은 해운대·수영·동래, 원도심, 서부산이 다르게 움직인다. 단기 판단: 선별 관망. 개발 뉴스보다 전세 회복과 입주 물량 충돌을 먼저 봐야 한다.' + when target.display_name like '대구광역시 수성구%' then '수성구는 대구 미분양 부담 속에서도 학군·핵심지 선호가 남아 있다. 단기 판단: 보수적 선별. 수성구 프리미엄은 전세가 회복되는 신축·준신축에서만 방어력이 높다.' + when target.display_name like '대구광역시%' then '대구는 미분양과 준공 후 미분양 부담이 아직 가격 상단을 제한한다. 단기 판단: 관망 우위. 핵심지 회복과 외곽 공급지 조정이 동시에 나타날 가능성이 높다.' + when target.display_name like '세종특별자치시%' then '세종은 행정수도 기대와 5생활권 공급 흡수가 동시에 확인돼야 한다. 단기 판단: 관망 우위. 정책 기대만 앞서면 변동성이 커지고, 입주 흡수가 확인되면 회복 논리가 생긴다.' + when target.display_name like '제주특별자치도%' then '제주는 관광 회복, 이주 수요, 주택 통계가 서로 다른 신호를 낸다. 단기 판단: 보수적 관망. 제주시와 서귀포·읍면을 섞어 보면 표본 착시가 커진다.' + when target.display_name like '광주광역시%' then '광주는 첨단산단과 도심 정비 기대가 있지만 생활권별 차이가 크다. 단기 판단: 선별 관망. 광산·북구 신축 수요와 동구·남구 정비 기대를 분리해야 한다.' + when target.display_name like '대전광역시 유성구%' then '유성구는 연구개발, 대학, 도안권 공급이 가격을 설명한다. 단기 판단: 선별 접근. 좋은 입지라도 입주 물량이 겹치면 전세가 먼저 약해질 수 있다.' + when target.display_name like '대전광역시%' then '대전은 유성·서구 중심 수요와 동구·대덕 정비·산업 수요가 다르다. 단기 판단: 선별 관망. 세종 연계 기대는 보조 변수이고 실제 판단은 전세와 입주 물량에 둬야 한다.' + when target.display_name like '울산광역시%' then '울산은 자동차·조선·석유화학 업황이 주거 수요를 직접 흔든다. 단기 판단: 선별 접근. 산업 회복이 전세 수요로 확인되면 긍정적이나 단일 기업 뉴스만으로는 부족하다.' + when target.display_name like '강원도%' then '강원은 춘천·원주의 상주 수요와 강릉·속초의 관광 수요가 다르다. 단기 판단: 보수적 관망. 세컨드하우스 분위기보다 연중 전세 수요와 거래 빈도를 확인해야 한다.' + when target.display_name like '충청북도 청주시%' then '청주는 오송·오창 산업축과 도심 생활수요가 충북에서 가장 두껍다. 단기 판단: 선별 접근. 산업 기대가 강할수록 분양가와 입주 물량도 같이 올라오므로 전세 방어가 우선이다.' + when target.display_name like '충청북도%' then '충북은 청주권 산업 수요와 충주·제천·군 단위 생활권의 거래 깊이가 다르다. 단기 판단: 관망 우위. 산업 뉴스가 약한 지역은 가격보다 거래 빈도와 전세 안정성이 더 중요하다.' + when target.display_name like '전라북도 전주시%' then '전주는 전북 실거주 수요의 중심이다. 단기 판단: 선별 관망. 새만금 기대를 전주 가격에 바로 붙이기보다 혁신도시·도심·신축 선호의 전세 확인이 필요하다.' + when target.display_name like '전라북도 군산시%' then '군산은 새만금과 산업단지 기대가 있지만 주택 수요 전환에는 시간이 걸린다. 단기 판단: 보수적 관망. 산업 뉴스보다 고용과 임대 수요가 먼저 움직이는지 확인해야 한다.' + when target.display_name like '전라북도%' then '전북은 전주 실거주, 군산·익산 산업축, 새만금 기대가 다르게 작동한다. 단기 판단: 관망 우위. 개발 기대보다 실제 거래와 전세 회복이 붙은 곳을 따로 봐야 한다.' + when target.display_name like '전라남도 여수시%' or target.display_name like '전라남도 순천시%' or target.display_name like '전라남도 광양시%' then '여수·순천·광양권은 산업단지와 생활권 수요가 전남에서 가장 뚜렷하다. 단기 판단: 선별 접근. 산업 경기와 상주 수요가 같이 받쳐주는 단지의 방어력이 높다.' + when target.display_name like '전라남도%' then '전남은 산업도시, 혁신도시, 해안 관광권의 거래 성격이 서로 다르다. 단기 판단: 보수적 관망. 군 단위는 한두 건의 실거래보다 인구와 임대 수요를 더 보수적으로 봐야 한다.' + when target.display_name like '경상북도 포항시%' then '포항은 철강·배터리 기대가 있지만 산업 경기 변동에 민감하다. 단기 판단: 선별 관망. 일자리 회복이 전세와 거래 수요로 같이 확인될 때 의미가 있다.' + when target.display_name like '경상북도 구미시%' then '구미는 전자·첨단산업 기반이 강하지만 경기 변동에 민감하다. 단기 판단: 선별 관망. 공장 증설 뉴스보다 임대 수요와 전입 흐름이 먼저 확인돼야 한다.' + when target.display_name like '경상북도%' then '경북은 포항·구미 산업축, 안동·예천 행정수요, 경주 관광권이 서로 다르다. 단기 판단: 관망 우위. 대구경북신공항 기대는 장기 변수로 보되 단기 판단은 거래와 전세가 우선이다.' + when target.display_name like '경상남도 창원시%' then '창원은 제조업 경기와 노후 주거지 정비가 같이 움직인다. 단기 판단: 선별 접근. 산업 회복이 전세 수요를 받쳐주면 긍정적이나 구축 정비 기대만으로는 부족하다.' + when target.display_name like '경상남도 김해시%' or target.display_name like '경상남도 양산시%' then '김해·양산은 부산권 배후 주거와 자체 산업 수요가 섞인다. 단기 판단: 선별 관망. 부산 접근성 기대와 신규 공급 부담을 분리해야 한다.' + when target.display_name like '경상남도 거제시%' or target.display_name like '경상남도 통영시%' then '거제·통영은 조선업과 관광 수요에 민감하다. 단기 판단: 보수적 관망. 업황 회복이 전세 안정으로 이어지는지 확인해야 한다.' + when target.display_name like '경상남도%' then '경남은 창원 제조업, 김해·양산 부산 배후, 거제·통영 조선·관광권이 다르다. 단기 판단: 선별 관망. 광역 평균보다 산업과 전세 수요가 붙는 생활권을 먼저 봐야 한다.' + when region.region_level = 'sido' then concat(target.display_name, '는 광역 평균만으로 판단하기 어려운 시장이다. 단기 판단: 관망 우위. 하위 시군구의 전세, 입주 물량, 산업 수요가 같은 방향인지 먼저 확인해야 한다.') + when target.display_name like '%군' then concat(target.display_name, '는 거래 표본이 얇은 지역이다. 단기 판단: 보수적 관망. 가격 방향보다 실거래 빈도, 전세 수요, 인구 흐름 유지 여부가 핵심이다.') + when target.display_name like '%구' then concat(target.display_name, '는 같은 광역시 안에서도 역세권, 학군, 신축 선호가 가격을 가른다. 단기 판단: 선별 관망. 단기 등락률보다 전세와 실거래가 함께 움직이는 단지를 봐야 한다.') + else concat(target.display_name, '는 자체 생활권 수요와 상위 광역권 흐름을 분리해야 하는 시장이다. 단기 판단: 관망 우위. 개발 뉴스보다 전세, 거래 빈도, 입주 물량의 균형이 우선이다.') + end as body, + case + when target.display_name like '서울특별시 서초구%' then '서리풀2지구 공급 시차' + when target.display_name like '서울특별시%' then '전세 압력 지속' + when target.display_name like '경기도 용인시 처인구%' then '국가산단 수요 전환' + when target.display_name like '경기도 부천시%' then '정비·공급 동시 모멘텀' + when target.display_name like '인천광역시 계양구%' then '계양테크노밸리 입주 흡수' + when target.display_name like '충청남도 아산시%' then '탕정·배방 직주근접 수요' + when target.display_name like '충청남도 천안시 서북구%' then '신축 전세 방어력' + when target.display_name like '충청남도 천안시 동남구%' then '역세권·대학가 실거주 수요' + when target.display_name like '충청남도 당진시%' then '제철·항만 고용 안정' + when target.display_name like '충청남도 서산시%' then '대산 산업단지 배후 수요' + when target.display_name like '충청남도%' then '천안·아산 전세 동행' + when target.display_name like '부산광역시%' then '동부산 전세 회복' + when target.display_name like '대구광역시%' then '미분양 축소 확인' + when target.display_name like '세종특별자치시%' then '5생활권 공급 흡수' + when target.display_name like '제주특별자치도%' then '상주 수요 회복' + when target.display_name like '울산광역시%' then '산업 업황 전세 전환' + when target.display_name like '전라남도 여수시%' or target.display_name like '전라남도 순천시%' or target.display_name like '전라남도 광양시%' then '산업단지 상주 수요' + when target.display_name like '경상남도 창원시%' then '제조업 전세 수요' + else '전세·실거래 동행' + end as expectation_1, + case + when region.region_level = 'sido' then '하위 생활권 차별화' + when target.display_name like '%군' then null + when target.display_name like '서울특별시%' then '정비사업 일정 구체화' + when target.display_name like '경기도%' or target.display_name like '인천광역시%' then '교통·산업축 실수요 확인' + when target.display_name like '충청남도%' then '산업·행정 수요 확인' + else '공급 일정 가시화' + end as expectation_2, + case + when target.display_name like '서울특별시 서초구%' then '토지거래허가 부담' + when target.display_name like '서울특별시%' then '기대 선반영 부담' + when target.display_name like '경기도 용인시 처인구%' then '산업 뉴스 선반영 위험' + when target.display_name like '경기도 부천시%' then '공급·정비 기대 충돌' + when target.display_name like '인천광역시 계양구%' then '대규모 공급 흡수 부담' + when target.display_name like '충청남도 아산시%' then '입주 물량 부담' + when target.display_name like '충청남도 천안시 서북구%' then '신축 분양가 부담' + when target.display_name like '충청남도 당진시%' then '산업 경기 의존도' + when target.display_name like '충청남도%' then '광역 평균 착시' + when target.display_name like '부산광역시%' then '개발 뉴스 선반영' + when target.display_name like '대구광역시%' then '준공 후 미분양 부담' + when target.display_name like '세종특별자치시%' then '공급 흡수 전 변동성' + when target.display_name like '제주특별자치도%' then '관광 수요 과대평가' + when target.display_name like '%군' then '거래 표본 부족' + else '입주 물량 부담' + end as concern_1, + case + when target.display_name like '%군' then null + when region.region_level = 'sido' then '약한 하위 지역 잔존' + when target.display_name like '서울특별시%' then '거래량 없는 신고가' + when target.display_name like '경기도%' or target.display_name like '인천광역시%' then '입주 시점 전세 약세' + when target.display_name like '충청남도%' then '축별 수요 편차' + else '전세 약세 전이' + end as concern_2, + case + when region.region_level = 'sido' then 0.83 + else 0.79 + end as confidence + from real_estate_regions region + join real_estate_targets target on target.id = region.target_id + where region.region_level in ('sido', 'sigungu') +) rewritten +left join real_estate_regional_reports existing on existing.target_id = rewritten.target_id +on duplicate key update + report_id = values(report_id), + report_version = values(report_version), + prompt_version = values(prompt_version), + model_name = values(model_name), + generated_by = values(generated_by), + title = values(title), + headline = values(headline), + summary = values(summary), + body = values(body), + expectation_points_json = values(expectation_points_json), + concern_points_json = values(concern_points_json), + data_quality = values(data_quality), + confidence = values(confidence), + as_of = values(as_of), + published_at = values(published_at), + updated_at = values(updated_at); diff --git a/backend/src/main/resources/db/migration/V58__real_estate_seed_canonical_molit_targets.sql b/backend/src/main/resources/db/migration/V58__real_estate_seed_canonical_molit_targets.sql new file mode 100644 index 0000000..342dc2e --- /dev/null +++ b/backend/src/main/resources/db/migration/V58__real_estate_seed_canonical_molit_targets.sql @@ -0,0 +1,33 @@ +insert into real_estate_market_data_targets ( + target_id, provider, provider_dataset, lawd_code, enabled, refresh_interval_hours, stale_after_hours, created_at, updated_at +) +select + region.target_id, + 'molit', + dataset.provider_dataset, + region.legal_dong_code, + true, + 24, + 72, + '2026-06-25 00:00:00', + '2026-06-25 00:00:00' +from real_estate_regions region +join ( + select 'molit_apt_trade' as provider_dataset + union all select 'molit_apt_rent' + union all select 'molit_offi_trade' + union all select 'molit_offi_rent' + union all select 'molit_rh_trade' + union all select 'molit_rh_rent' + union all select 'molit_sh_trade' + union all select 'molit_sh_rent' + union all select 'molit_silv_trade' +) dataset +where region.target_id in ('region-seoul-jongno', 'region-seoul-mapo') + and region.legal_dong_code is not null + and length(region.legal_dong_code) = 5 +on duplicate key update + enabled = values(enabled), + refresh_interval_hours = values(refresh_interval_hours), + stale_after_hours = values(stale_after_hours), + updated_at = values(updated_at); diff --git a/backend/src/main/resources/db/migration/V59__real_estate_regional_assessment_report_rewrite.sql b/backend/src/main/resources/db/migration/V59__real_estate_regional_assessment_report_rewrite.sql new file mode 100644 index 0000000..8b510ee --- /dev/null +++ b/backend/src/main/resources/db/migration/V59__real_estate_regional_assessment_report_rewrite.sql @@ -0,0 +1,299 @@ +insert into real_estate_regional_reports ( + target_id, + report_id, + report_version, + prompt_version, + model_name, + generated_by, + title, + headline, + summary, + body, + expectation_points_json, + concern_points_json, + data_quality, + confidence, + as_of, + published_at, + created_at, + updated_at +) +select + rewritten.target_id, + concat('regional-report-', rewritten.target_id, '-regional-assessment-20260625'), + 'regional-report-assessment-v4', + 'codex-regional-assessment-rewrite-prompt-20260625', + 'codex-gpt-5-regional-assessment', + 'codex-quality-rewrite:regional-assessment-20260625', + concat(rewritten.display_name, ' 지역 평가 리포트'), + rewritten.headline, + rewritten.summary, + rewritten.body, + case + when rewritten.expectation_2 is null then concat('["', rewritten.expectation_1, '"]') + else concat('["', rewritten.expectation_1, '","', rewritten.expectation_2, '"]') + end, + case + when rewritten.concern_2 is null then concat('["', rewritten.concern_1, '"]') + else concat('["', rewritten.concern_1, '","', rewritten.concern_2, '"]') + end, + 'deep_researched', + rewritten.confidence, + '2026-06-25 00:00:00', + '2026-06-25 03:20:00', + coalesce(existing.created_at, '2026-06-25 03:20:00'), + '2026-06-25 03:20:00' +from ( + select + base.target_id, + base.region_level, + base.display_name, + case + when base.target_id = 'region-seoul' then '서울은 전세 압력과 핵심지 정비 기대가 가격 하방을 동시에 받치는 시장' + when base.target_id = 'region-seoul-gangnam' then '강남구는 학군·업무·정비사업 프리미엄이 분리되는 초핵심 시장' + when base.target_id = 'region-seoul-seocho' then '서초구는 서리풀2지구와 토지거래허가 부담을 함께 봐야 하는 시장' + when base.target_id = 'region-seoul-songpa' then '송파구는 잠실 대장 단지와 가락·문정 실수요가 갈리는 동남권 시장' + when base.target_id = 'region-seoul-yongsan' then '용산구는 개발 기대와 실입주 수요의 가격 간극이 큰 고가 시장' + when base.target_id = 'region-seoul-mapo' then '마포구는 상암·공덕·홍대 업무·상권 수요가 분산된 강북 핵심지' + when base.target_id = 'region-seoul-nowon' then '노원구는 노후 단지 정비 기대와 실수요 가격대가 충돌하는 시장' + when base.target_id in ('region-gyeonggi', 'region-41000') then '경기도는 정비·신도시 공급·산업축을 분리해야 하는 광역 시장' + when base.target_id = 'region-gyeonggi-yonginsicheoingu' then '용인 처인구는 반도체 국가산단 기대의 주거 수요 전환이 핵심' + when base.target_id = 'region-gyeonggi-bucheon' then '부천은 부천대장 공급과 중동 정비 기대가 동시에 작동하는 시장' + when base.target_id = 'region-incheon-gyeyang' then '계양구는 계양테크노밸리와 17천호 공급 흡수가 핵심인 시장' + when base.display_name = '충청남도' then '충남은 천안·아산, 내포, 서해안 산업권을 분리해야 하는 광역 시장' + when base.display_name like '충청남도 아산시%' then '아산은 탕정·배방 산업 배후와 천안 연계 수요가 가격을 가르는 시장' + when base.display_name like '충청남도 천안시 서북구%' then '천안 서북구는 신축 선호와 수도권 남부 출퇴근 수요가 겹친 시장' + when base.display_name like '충청남도 천안시 동남구%' then '천안 동남구는 원도심·대학·역세권 수요의 방어력 확인 구간' + when base.display_name like '충청남도 당진시%' then '당진은 제철·항만 고용과 임대 수요가 가격 하방을 결정하는 시장' + when base.display_name like '충청남도 서산시%' then '서산은 대산 산업단지와 자족 생활권 수요가 가격 설명력을 갖는 시장' + when base.display_name like '충청남도 보령시%' then '보령은 해양관광 체류 수요와 상주 주거 수요를 분리해야 하는 시장' + when base.display_name like '충청남도 공주시%' then '공주는 세종 배후 저가 수요와 대학·원도심 수요가 얇게 겹치는 시장' + when base.display_name like '충청남도 계룡시%' then '계룡은 대전 배후와 군 주거 수요가 시장 규모를 정하는 소형 시장' + when base.display_name like '충청남도 논산시%' then '논산은 국방·대학 임대 수요와 원도심 노후 주거가 공존하는 시장' + when base.display_name like '충청남도 홍성군%' then '홍성은 내포 행정수요와 홍성 원도심 수요가 분리되는 시장' + when base.display_name like '충청남도 예산군%' then '예산은 내포 배후 기대와 예산 원도심 유동성이 갈리는 시장' + when base.display_name like '충청남도 태안군%' then '태안은 관광·세컨드하우스 수요와 상주 수요의 괴리가 큰 시장' + when base.display_name like '충청남도 부여군%' then '부여는 백제문화 관광 수요보다 상주 인구와 거래 유동성이 중요한 시장' + when base.display_name like '충청남도 서천군%' then '서천은 장항·군산 연계와 해안 생활권 수요가 제한적인 시장' + when base.display_name like '충청남도 금산군%' then '금산은 대전권 배후와 농산업 수요가 얇게 작동하는 저유동성 시장' + when base.display_name like '충청남도 청양군%' then '청양은 소형 정주 수요와 인구 감소 압력을 함께 봐야 하는 시장' + when base.display_name like '부산광역시 해운대구%' then '해운대는 북항보다 동부산 고급 주거와 센텀 업무 수요가 직접 변수' + when base.display_name like '부산광역시%' then '부산은 북항·가덕도 심리와 생활권별 전세 회복이 분리되는 시장' + when base.display_name like '대구광역시 수성구%' then '수성구는 미분양 부담 속에서도 학군·핵심지 선호가 버티는 시장' + when base.display_name like '대구광역시%' then '대구는 미분양과 준공 후 미분양 부담이 가격 상단을 제한하는 시장' + when base.display_name like '세종특별자치시%' then '세종은 행정수도 기대와 5생활권 공급 흡수가 동시에 필요한 시장' + when base.display_name like '제주특별자치도%' then '제주는 관광·이주 수요와 주택 통계 신호가 갈리는 시장' + when base.display_name like '광주광역시%' then '광주는 첨단산단 기대와 생활권별 신축 수요가 분리되는 시장' + when base.display_name like '대전광역시 유성구%' then '유성구는 연구개발 수요와 도안권 공급 부담이 함께 작동하는 시장' + when base.display_name like '대전광역시%' then '대전은 유성·서구 중심 수요와 도안권 공급 부담을 같이 봐야 하는 시장' + when base.display_name like '울산광역시%' then '울산은 자동차·조선·석유화학 업황이 주거 수요를 좌우하는 시장' + when base.display_name like '강원도%' then '강원은 춘천·원주 상주 수요와 영동 관광 수요가 분리되는 시장' + when base.display_name like '충청북도 청주시%' then '청주는 오송·오창 산업축과 도심 생활수요가 함께 작동하는 시장' + when base.display_name like '충청북도%' then '충북은 청주권 산업 수요와 중부내륙 거래 깊이 차이가 큰 시장' + when base.display_name like '전라북도 전주시%' then '전주는 전북 실거주 수요의 중심축' + when base.display_name like '전라북도 군산시%' then '군산은 새만금·산단 기대의 주거 수요 전환 속도가 핵심인 시장' + when base.display_name like '전라북도%' then '전북은 전주 생활수요와 새만금 산업 기대를 분리해야 하는 시장' + when base.display_name like '전라남도 여수시%' or base.display_name like '전라남도 순천시%' or base.display_name like '전라남도 광양시%' then '여수·순천·광양권은 산업단지와 생활권 수요가 겹치는 전남 핵심축' + when base.display_name like '전라남도%' then '전남은 산업도시·혁신도시·해안 관광권의 거래 성격이 분리되는 시장' + when base.display_name like '경상북도 포항시%' then '포항은 철강·배터리 기대와 산업 경기 변동성이 공존하는 시장' + when base.display_name like '경상북도 구미시%' then '구미는 전자·첨단산업 회복의 전세 전환이 핵심인 시장' + when base.display_name like '경상북도%' then '경북은 포항·구미 산업축과 안동·예천 행정수요가 분리되는 시장' + when base.display_name like '경상남도 창원시%' then '창원은 제조업 경기와 노후 주거지 정비가 함께 작동하는 시장' + when base.display_name like '경상남도 김해시%' or base.display_name like '경상남도 양산시%' then '김해·양산은 부산권 배후 주거와 자체 산업 수요가 혼재된 시장' + when base.display_name like '경상남도 거제시%' or base.display_name like '경상남도 통영시%' then '거제·통영은 조선업과 관광 수요에 민감한 해안 생활권' + when base.display_name like '경상남도%' then '경남은 창원 제조업과 부산권 배후, 남해안 산업권이 분리되는 시장' + when base.region_level = 'sido' then concat(base.display_name, '는 하위 생활권별 수급 차이가 큰 광역 시장') + when base.display_name like '%군' then concat(base.local_name, ' 주택시장은 거래 표본과 상주 수요 확인이 우선인 저유동성 시장') + when base.display_name like '%구' then concat(base.local_name, ' 주택시장은 역세권·학군·신축 선호가 가격을 가르는 세부 시장') + else concat(base.local_name, ' 주택시장은 자체 생활권 수요와 광역 흐름을 함께 봐야 하는 시장') + end as headline, + '실거래·전세·공급·정책 변수를 지역별 평가와 전망으로 압축한 리포트입니다.' as summary, + case + when base.target_id = 'region-seoul' then '평가: 서울은 강남3구와 용산이 가격 발견을 주도하고, 전세 압력은 강북 노후 단지까지 하방을 받치는 구조다. 전망: 회복 가능성 우위. 다만 서리풀2지구 2,000호는 장기 공급 변수라 단기 가격 논리보다 착공·분양 일정 확인이 우선이다.' + when base.target_id = 'region-seoul-gangnam' then '평가: 강남구는 대치·청담·삼성·개포·압구정이 각각 다른 가격표를 가진 시장이다. 전망: 방어력 우위. 학군·업무 접근성은 강하지만 전세가율, 토지거래허가, 정비사업 속도가 같이 확인돼야 한다.' + when base.target_id = 'region-seoul-seocho' then '평가: 서초구는 서리풀2지구, 반포·잠원 정비 기대, 토지거래허가 부담이 공존한다. 전망: 선별 관망. 핵심 신축 방어력은 높지만 기대 선반영 구간은 전세 확인 없이는 부담이 커진다.' + when base.target_id = 'region-seoul-songpa' then '평가: 송파구는 잠실 대장 단지와 가락·문정 실거주 수요가 다른 속도로 움직인다. 전망: 선별 회복 가능성. 주변 구축까지 실거래가 동행할 때 상승 신뢰도가 높아진다.' + when base.target_id = 'region-seoul-yongsan' then '평가: 용산구는 국제업무지구와 한남·이촌 정비 기대가 강하지만 실입주 수요의 가격대가 얇다. 전망: 보수적 관망. 거래량 없이 기대만 오른 구간은 변동성 확대 위험이 크다.' + when base.target_id = 'region-seoul-mapo' then '평가: 마포구는 상암·공덕·홍대 수요가 분산된 시장이고, 강남권 대체재 논리는 공덕·아현 전세와 상암 업무 수요가 같이 버틸 때 유효하다. 전망: 선별 회복 가능성. 확인 지표는 전세 방어와 직주근접 거래 반복이다.' + when base.target_id = 'region-seoul-nowon' then '평가: 노원구는 노후 단지 비중이 높고 재건축 기대와 실수요 가격대가 충돌하는 시장이다. 전망: 관망 우위. 전세가 먼저 회복되지 않으면 정비 기대만으로 매매 회복을 설명하기 어렵다.' + when base.target_id in ('region-gyeonggi', 'region-41000') then '평가: 경기도는 1기 신도시 정비, 3기 신도시, 용인 반도체가 각각 다른 수급 경로를 만든다. 전망: 선별 회복 가능성. 서울 대체재, 산업 배후, 교통 기대 지역을 한 결론으로 묶으면 판단이 흐려진다.' + when base.target_id = 'region-gyeonggi-yonginsicheoingu' then '평가: 용인 처인구는 첨단시스템반도체 국가산단 이슈가 핵심 프리미엄이지만, 산업 뉴스가 곧바로 주거 수요로 전환되지는 않는다. 전망: 선별 관망. 원삼·남사 전세와 입주 흡수가 먼저 확인돼야 한다.' + when base.target_id = 'region-gyeonggi-bucheon' then '평가: 부천은 부천대장 공급과 중동 1기 신도시 정비가 동시에 작동한다. 전망: 보수적 선별. 새 아파트 선호와 구축 재건축 프리미엄이 충돌해 생활권 내부 격차가 커질 수 있다.' + when base.target_id = 'region-incheon-gyeyang' then '평가: 계양구는 계양테크노밸리와 17천호 공급이 함께 들어오는 구간이다. 전망: 보수적 관망. 서울 접근성보다 입주 시점 전세 방어와 검단·부천대장 수요 중복이 더 중요하다.' + when base.display_name = '충청남도' then '평가: 충남은 천안·아산의 수도권 남부 수요, 내포 행정수요, 당진·서산의 서해안 산업축이 분리되는 시장이다. 전망: 선별 관망. 광역 평균보다 축별 전세 방어와 실거래 반복 여부가 가격 판단의 핵심이다.' + when base.display_name like '충청남도 아산시%' then '평가: 아산은 천안과 붙어 있는 수요, 탕정·배방 산업 배후, 새 아파트 선호가 핵심이다. 전망: 선별 회복 가능성. 입주 물량이 겹치면 전세가 먼저 압박받으므로 전세 방어가 확인되는 단지만 프리미엄 유지력이 있다.' + when base.display_name like '충청남도 천안시 서북구%' then '평가: 천안 서북구는 불당·성성·두정 신축 선호와 수도권 남부 출퇴근 수요가 가격을 만든다. 전망: 회복 가능성 우위. 다만 신축 분양가와 전세가 벌어지면 상승 지속성은 약해진다.' + when base.display_name like '충청남도 천안시 동남구%' then '평가: 천안 동남구는 원도심, 대학가, 역세권 수요가 중심이고 서북구 신축 흐름을 그대로 따라가기 어렵다. 전망: 관망 우위. 저가 실거주와 정비 기대가 실제 거래로 반복되는지 확인해야 한다.' + when base.display_name like '충청남도 당진시%' then '평가: 당진은 제철·항만 산업 경기와 임대 수요가 가격 하방을 결정한다. 전망: 보수적 관망. 산업 회복은 긍정적이나 고용 확인 전 산업 뉴스만으로 상승 지속성을 보기 어렵다.' + when base.display_name like '충청남도 서산시%' then '평가: 서산은 대산 산업단지와 자족 생활권이 가격을 설명한다. 전망: 선별 관망. 산업 배후 수요가 뚜렷한 단지와 외곽 택지의 거래 깊이를 분리해야 한다.' + when base.display_name like '충청남도 보령시%' then '평가: 보령은 해양관광 체류 수요가 있으나 상주 수요의 두께는 제한적이다. 전망: 보수적 관망. 관광 뉴스보다 연중 전세 수요와 실거래 반복성이 가격 방어력을 좌우한다.' + when base.display_name like '충청남도 공주시%' then '평가: 공주는 세종 배후 저가 수요와 대학·원도심 수요가 얇게 겹친다. 전망: 관망 우위. 세종 접근성 기대만으로는 부족하고 대학가 임대와 원도심 거래 회복이 같이 확인돼야 한다.' + when base.display_name like '충청남도 계룡시%' then '평가: 계룡은 대전 배후 주거와 군 주거 수요가 시장 규모를 정하는 소형 시장이다. 전망: 선별 관망. 공급이 조금만 늘어도 전세가 흔들릴 수 있어 유동성 확인이 먼저다.' + when base.display_name like '충청남도 논산시%' then '평가: 논산은 국방·대학 임대 수요가 방어 요인이지만 원도심 노후 주거 비중이 높다. 전망: 보수적 관망. 임대 수요가 매매 회복으로 전환되는 속도는 느리게 보는 편이 맞다.' + when base.display_name like '충청남도 홍성군%' then '평가: 홍성은 내포 행정수요가 장점이지만 홍성 원도심과 내포 신축의 가격 논리가 다르다. 전망: 관망 우위. 전세 공실과 입주 흡수 속도 확인 전까지 회복 판단은 보수적이다.' + when base.display_name like '충청남도 예산군%' then '평가: 예산은 내포 배후 기대가 있으나 예산 원도심 거래 유동성은 얇다. 전망: 관망 우위. 행정수요가 실제 전세와 매매 거래로 반복되는지가 핵심이다.' + when base.display_name like '충청남도 태안군%' then '평가: 태안은 관광·세컨드하우스 수요가 가격 기대를 만들 수 있지만 상주 수요는 얇다. 전망: 보수적 관망. 성수기 분위기보다 연중 거래 빈도와 임대 안정성이 우선이다.' + when base.display_name like '충청남도 부여군%' then '평가: 부여는 백제문화 관광 수요가 보조 변수지만 주택시장은 상주 인구와 거래 유동성이 더 중요하다. 전망: 보수적 관망. 고령화와 얇은 거래 표본이 가격 회복 속도를 제한한다.' + when base.display_name like '충청남도 서천군%' then '평가: 서천은 장항·군산 연계 수요가 있으나 자체 주거 수요의 깊이는 제한적이다. 전망: 보수적 관망. 산업·해안 이슈보다 전세 유지와 실거래 빈도 확인이 우선이다.' + when base.display_name like '충청남도 금산군%' then '평가: 금산은 대전권 배후와 농산업 수요가 얇게 작동하는 시장이다. 전망: 보수적 관망. 외부 수요 유입보다 인구 흐름과 전세 유지가 가격 방어의 핵심이다.' + when base.display_name like '충청남도 청양군%' then '평가: 청양은 소형 정주 수요가 중심이고 거래 표본이 얇아 가격 발견력이 낮다. 전망: 보수적 관망. 인구 감소 압력과 거래 공백이 이어지면 회복 신호가 늦게 나타난다.' + when base.display_name like '부산광역시 해운대구%' then '평가: 해운대는 북항 이슈보다 해운대·센텀·마린시티의 전세 회복과 고가 주거 선호가 직접 변수다. 전망: 선별 회복 가능성. 해안 고가 단지와 내륙 구축의 격차가 커질 수 있다.' + when base.display_name like '부산광역시%' then '평가: 부산은 북항·가덕도 기대가 도시 심리를 만들지만 가격은 해운대·수영·동래, 원도심, 서부산이 다르게 움직인다. 전망: 선별 관망. 개발 뉴스보다 전세 회복과 입주 물량 충돌이 중요하다.' + when base.display_name like '대구광역시 수성구%' then '평가: 수성구는 대구 미분양 부담 속에서도 학군·핵심지 선호가 남아 있다. 전망: 보수적 선별. 수성구 프리미엄은 전세가 회복되는 신축·준신축에서만 방어력이 높다.' + when base.display_name like '대구광역시%' then '평가: 대구는 미분양과 준공 후 미분양 부담이 아직 가격 상단을 제한한다. 전망: 관망 우위. 핵심지 회복과 외곽 공급지 조정이 동시에 나타날 가능성이 높다.' + when base.display_name like '세종특별자치시%' then '평가: 세종은 행정수도 기대와 5생활권 공급 흡수가 동시에 확인돼야 한다. 전망: 관망 우위. 정책 기대만 앞서면 변동성이 커지고, 입주 흡수가 확인되면 회복 논리가 생긴다.' + when base.display_name like '제주특별자치도%' then '평가: 제주는 관광 회복, 이주 수요, 주택 통계가 서로 다른 신호를 낸다. 전망: 보수적 관망. 제주시와 서귀포·읍면을 섞어 보면 표본 착시가 커진다.' + when base.display_name like '광주광역시%' then '평가: 광주는 첨단산단과 도심 정비 기대가 있지만 생활권별 차이가 크다. 전망: 선별 관망. 광산·북구 신축 수요와 동구·남구 정비 기대를 분리해야 한다.' + when base.display_name like '대전광역시 유성구%' then '평가: 유성구는 연구개발, 대학, 도안권 공급이 가격을 설명한다. 전망: 선별 회복 가능성. 좋은 입지라도 입주 물량이 겹치면 전세가 먼저 약해질 수 있다.' + when base.display_name like '대전광역시%' then '평가: 대전은 유성·서구 중심 수요와 동구·대덕 정비·산업 수요가 다르다. 전망: 선별 관망. 세종 연계 기대는 보조 변수이고 실제 판단은 전세와 입주 물량에 둬야 한다.' + when base.display_name like '울산광역시%' then '평가: 울산은 자동차·조선·석유화학 업황이 주거 수요를 직접 흔든다. 전망: 선별 회복 가능성. 산업 회복이 전세 수요로 확인되면 긍정적이나 단일 기업 뉴스만으로는 부족하다.' + when base.display_name like '강원도%' then '평가: 강원은 춘천·원주의 상주 수요와 강릉·속초의 관광 수요가 다르다. 전망: 보수적 관망. 세컨드하우스 분위기보다 연중 전세 수요와 거래 빈도를 확인해야 한다.' + when base.display_name like '충청북도 청주시%' then '평가: 청주는 오송·오창 산업축과 도심 생활수요가 충북에서 가장 두껍다. 전망: 선별 회복 가능성. 산업 기대가 강할수록 분양가와 입주 물량도 같이 올라오므로 전세 방어가 우선이다.' + when base.display_name like '충청북도%' then '평가: 충북은 청주권 산업 수요와 충주·제천·군 단위 생활권의 거래 깊이가 다르다. 전망: 관망 우위. 산업 뉴스가 약한 지역은 가격보다 거래 빈도와 전세 안정성이 더 중요하다.' + when base.display_name like '전라북도 전주시%' then '평가: 전주는 전북 실거주 수요의 중심이다. 전망: 선별 관망. 새만금 기대를 전주 가격에 바로 붙이기보다 혁신도시·도심·신축 선호의 전세 확인이 필요하다.' + when base.display_name like '전라북도 군산시%' then '평가: 군산은 새만금과 산업단지 기대가 있지만 주택 수요 전환에는 시간이 걸린다. 전망: 보수적 관망. 산업 뉴스보다 고용과 임대 수요가 먼저 움직이는지 확인해야 한다.' + when base.display_name like '전라북도%' then '평가: 전북은 전주 실거주, 군산·익산 산업축, 새만금 기대가 다르게 작동한다. 전망: 관망 우위. 개발 기대보다 실제 거래와 전세 회복이 붙은 곳을 따로 봐야 한다.' + when base.display_name like '전라남도 여수시%' or base.display_name like '전라남도 순천시%' or base.display_name like '전라남도 광양시%' then '평가: 여수·순천·광양권은 산업단지와 생활권 수요가 전남에서 가장 뚜렷하다. 전망: 선별 회복 가능성. 산업 경기와 상주 수요가 같이 받쳐주는 단지의 방어력이 높다.' + when base.display_name like '전라남도%' then '평가: 전남은 산업도시, 혁신도시, 해안 관광권의 거래 성격이 서로 다르다. 전망: 보수적 관망. 군 단위는 한두 건의 실거래보다 인구와 임대 수요를 더 보수적으로 봐야 한다.' + when base.display_name like '경상북도 포항시%' then '평가: 포항은 철강·배터리 기대가 있지만 산업 경기 변동에 민감하다. 전망: 선별 관망. 일자리 회복이 전세와 거래 수요로 같이 확인될 때 의미가 있다.' + when base.display_name like '경상북도 구미시%' then '평가: 구미는 전자·첨단산업 기반이 강하지만 경기 변동에 민감하다. 전망: 선별 관망. 공장 증설 뉴스보다 임대 수요와 전입 흐름이 먼저 확인돼야 한다.' + when base.display_name like '경상북도%' then '평가: 경북은 포항·구미 산업축, 안동·예천 행정수요, 경주 관광권이 서로 다르다. 전망: 관망 우위. 대구경북신공항 기대는 장기 변수로 보되 단기 판단은 거래와 전세가 우선이다.' + when base.display_name like '경상남도 창원시%' then '평가: 창원은 제조업 경기와 노후 주거지 정비가 같이 움직인다. 전망: 선별 회복 가능성. 산업 회복이 전세 수요를 받쳐주면 긍정적이나 구축 정비 기대만으로는 부족하다.' + when base.display_name like '경상남도 김해시%' or base.display_name like '경상남도 양산시%' then '평가: 김해·양산은 부산권 배후 주거와 자체 산업 수요가 섞인다. 전망: 선별 관망. 부산 접근성 기대와 신규 공급 부담을 분리해야 한다.' + when base.display_name like '경상남도 거제시%' or base.display_name like '경상남도 통영시%' then '평가: 거제·통영은 조선업과 관광 수요에 민감하다. 전망: 보수적 관망. 업황 회복이 전세 안정으로 이어지는지 확인해야 한다.' + when base.display_name like '경상남도%' then '평가: 경남은 창원 제조업, 김해·양산 부산 배후, 거제·통영 조선·관광권이 다르다. 전망: 선별 관망. 광역 평균보다 산업과 전세 수요가 붙는 생활권을 먼저 봐야 한다.' + when base.region_level = 'sido' then concat('평가: ', base.display_name, ' 주택시장은 광역 평균만으로 판단하기 어려운 시장이다. 전망: 관망 우위. 하위 시군구의 전세, 입주 물량, 산업 수요가 같은 방향인지 먼저 확인해야 한다.') + when base.display_name like '%군' then concat('평가: ', base.local_name, ' 주택시장은 거래 표본이 얇고 상주 수요 확인이 우선이다. 전망: 보수적 관망. 가격 방향보다 실거래 빈도, 전세 수요, 인구 흐름 유지 여부가 핵심이다.') + when base.display_name like '%구' then concat('평가: ', base.local_name, ' 주택시장은 같은 광역시 안에서도 역세권, 학군, 신축 선호가 가격을 가른다. 전망: 선별 관망. 단기 등락률보다 전세와 실거래가 함께 움직이는 단지를 봐야 한다.') + else concat('평가: ', base.local_name, ' 주택시장은 자체 생활권 수요와 상위 광역권 흐름을 분리해야 하는 시장이다. 전망: 관망 우위. 개발 뉴스보다 전세, 거래 빈도, 입주 물량의 균형이 우선이다.') + end as body, + case + when base.display_name like '서울특별시 서초구%' then '서리풀2지구 공급 시차' + when base.display_name like '서울특별시%' then '전세 압력 지속' + when base.display_name like '경기도 용인시 처인구%' then '국가산단 수요 전환' + when base.display_name like '경기도 부천시%' then '정비·공급 동시 모멘텀' + when base.display_name like '인천광역시 계양구%' then '계양테크노밸리 입주 흡수' + when base.display_name like '충청남도 아산시%' then '탕정·배방 직주근접 수요' + when base.display_name like '충청남도 천안시 서북구%' then '신축 전세 방어력' + when base.display_name like '충청남도 천안시 동남구%' then '역세권·대학가 실거주 수요' + when base.display_name like '충청남도 당진시%' then '제철·항만 고용 안정' + when base.display_name like '충청남도 서산시%' then '대산 산단 배후 수요' + when base.display_name like '충청남도 보령시%' then '해양관광 체류 수요' + when base.display_name like '충청남도 공주시%' then '세종 배후 저가 수요' + when base.display_name like '충청남도 계룡시%' then '대전 배후 군 주거' + when base.display_name like '충청남도 논산시%' then '국방·대학 임대 수요' + when base.display_name like '충청남도 홍성군%' then '내포 행정수요 흡수' + when base.display_name like '충청남도 예산군%' then '내포 배후 정주 수요' + when base.display_name like '충청남도 태안군%' then '관광 체류 수요' + when base.display_name like '충청남도 부여군%' then '백제문화 관광 수요' + when base.display_name like '충청남도 서천군%' then '장항·군산 연계 수요' + when base.display_name like '충청남도 금산군%' then '대전권 농산업 배후' + when base.display_name like '충청남도 청양군%' then '소형 정주 수요' + when base.display_name like '부산광역시%' then '동부산 전세 회복' + when base.display_name like '대구광역시%' then '미분양 축소 확인' + when base.display_name like '세종특별자치시%' then '5생활권 공급 흡수' + when base.display_name like '제주특별자치도%' then '상주 수요 회복' + when base.display_name like '울산광역시%' then '산업 업황 전세 전환' + when base.display_name like '전라남도 여수시%' or base.display_name like '전라남도 순천시%' or base.display_name like '전라남도 광양시%' then '산업단지 상주 수요' + when base.display_name like '경상남도 창원시%' then '제조업 전세 수요' + when base.region_level = 'sido' then concat(base.local_name, ' 생활권 차별화') + when base.display_name like '%군' then concat(base.local_name, ' 거래 회복 확인') + when base.display_name like '%구' then concat(base.local_name, ' 전세 방어 확인') + else concat(base.local_name, ' 실거래 회복') + end as expectation_1, + case + when base.region_level = 'sido' then '하위 생활권 차별화' + when base.display_name like '%군' then null + when base.display_name like '서울특별시%' then '정비사업 일정 구체화' + when base.display_name like '경기도%' or base.display_name like '인천광역시%' then '교통·산업축 실수요 확인' + when base.display_name like '충청남도%' then concat(base.local_name, ' 전세 유지') + else concat(base.local_name, ' 공급 일정 확인') + end as expectation_2, + case + when base.display_name like '서울특별시 서초구%' then '토지거래허가 부담' + when base.display_name like '서울특별시%' then '기대 선반영 부담' + when base.display_name like '경기도 용인시 처인구%' then '산업 뉴스 선반영 위험' + when base.display_name like '경기도 부천시%' then '공급·정비 기대 충돌' + when base.display_name like '인천광역시 계양구%' then '대규모 공급 흡수 부담' + when base.display_name like '충청남도 아산시%' then '입주 물량 부담' + when base.display_name like '충청남도 천안시 서북구%' then '신축 분양가 부담' + when base.display_name like '충청남도 천안시 동남구%' then '원도심 회복 지연' + when base.display_name like '충청남도 당진시%' then '산업 경기 의존도' + when base.display_name like '충청남도 서산시%' then '외곽 택지 유동성 부족' + when base.display_name like '충청남도 보령시%' then '상주 수요 부족' + when base.display_name like '충청남도 공주시%' then '원도심 거래 얇음' + when base.display_name like '충청남도 계룡시%' then '소형 시장 유동성' + when base.display_name like '충청남도 논산시%' then '노후 주거 비중' + when base.display_name like '충청남도 홍성군%' then '내포 공급 흡수 부담' + when base.display_name like '충청남도 예산군%' then '원도심 유동성 부족' + when base.display_name like '충청남도 태안군%' then '관광 수요 과대평가' + when base.display_name like '충청남도 부여군%' then '고령화·거래 부족' + when base.display_name like '충청남도 서천군%' then '자체 수요 한계' + when base.display_name like '충청남도 금산군%' then '외부 유입 제한' + when base.display_name like '충청남도 청양군%' then '인구 감소 압력' + when base.display_name like '부산광역시%' then '개발 뉴스 선반영' + when base.display_name like '대구광역시%' then '준공 후 미분양 부담' + when base.display_name like '세종특별자치시%' then '공급 흡수 전 변동성' + when base.display_name like '제주특별자치도%' then '관광 수요 과대평가' + when base.display_name like '%군' then concat(base.local_name, ' 유동성 부족') + when base.display_name like '%구' then concat(base.local_name, ' 신축·구축 격차') + else concat(base.local_name, ' 입주·전세 부담') + end as concern_1, + case + when base.display_name like '%군' then null + when base.region_level = 'sido' then '약한 하위 지역 잔존' + when base.display_name like '서울특별시%' then '거래량 없는 신고가' + when base.display_name like '경기도%' or base.display_name like '인천광역시%' then '입주 시점 전세 약세' + when base.display_name like '충청남도%' then concat(base.local_name, ' 수요 편차') + else concat(base.local_name, ' 전세 약세 전이') + end as concern_2, + case + when base.region_level = 'sido' then 0.84 + else 0.80 + end as confidence + from ( + select + region.target_id, + region.region_level, + target.display_name, + replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(target.display_name, + '서울특별시 ', ''), + '부산광역시 ', ''), + '대구광역시 ', ''), + '인천광역시 ', ''), + '광주광역시 ', ''), + '대전광역시 ', ''), + '울산광역시 ', ''), + '세종특별자치시 ', ''), + '경기도 ', ''), + '강원도 ', ''), + '충청북도 ', ''), + '충청남도 ', ''), + '전라북도 ', ''), + '전라남도 ', ''), + '경상북도 ', ''), + '경상남도 ', ''), + '제주특별자치도 ', '') as local_name + from real_estate_regions region + join real_estate_targets target on target.id = region.target_id + where region.region_level in ('sido', 'sigungu') + ) base +) rewritten +left join real_estate_regional_reports existing on existing.target_id = rewritten.target_id +on duplicate key update + report_id = values(report_id), + report_version = values(report_version), + prompt_version = values(prompt_version), + model_name = values(model_name), + generated_by = values(generated_by), + title = values(title), + headline = values(headline), + summary = values(summary), + body = values(body), + expectation_points_json = values(expectation_points_json), + concern_points_json = values(concern_points_json), + data_quality = values(data_quality), + confidence = values(confidence), + as_of = values(as_of), + published_at = values(published_at), + updated_at = values(updated_at); diff --git a/backend/src/main/resources/db/migration/V60__real_estate_regional_assessment_phrase_cleanup.sql b/backend/src/main/resources/db/migration/V60__real_estate_regional_assessment_phrase_cleanup.sql new file mode 100644 index 0000000..ed5a05f --- /dev/null +++ b/backend/src/main/resources/db/migration/V60__real_estate_regional_assessment_phrase_cleanup.sql @@ -0,0 +1,5 @@ +update real_estate_regional_reports +set body = replace(body, '단기 판단은 거래와 전세가 우선이다.', '단기 해석은 거래와 전세가 우선이다.'), + updated_at = '2026-06-25 03:30:00' +where generated_by = 'codex-quality-rewrite:regional-assessment-20260625' + and body like '%단기 판단은 거래와 전세가 우선이다.%'; diff --git a/backend/src/main/resources/db/migration/V61__real_estate_capital_area_report_point_diversification.sql b/backend/src/main/resources/db/migration/V61__real_estate_capital_area_report_point_diversification.sql new file mode 100644 index 0000000..d3b0e9f --- /dev/null +++ b/backend/src/main/resources/db/migration/V61__real_estate_capital_area_report_point_diversification.sql @@ -0,0 +1,135 @@ +update real_estate_regional_reports +set report_version = 'regional-report-assessment-v5', + prompt_version = 'codex-regional-assessment-diversification-prompt-20260625', + model_name = 'codex-gpt-5-regional-assessment', + headline = '화성시는 GTX-A 동탄역 접근성과 반도체 배후 수요, 외곽 택지 입주 부담이 동시에 작동하는 시장', + summary = '화성시는 동탄역권과 삼성 화성·기흥 반도체 배후 수요가 가격 하방을 만들지만, 동탄2·봉담·남양권 입주 흡수 여부가 상승 지속성을 제한하는 구간입니다.', + body = '평가: 화성시는 동탄역권과 삼성 화성·기흥 반도체 배후 수요가 가장 선명하고, 봉담·남양·향남은 같은 화성 안에서도 가격 설명력이 다릅니다. +전망: 선별 관망. GTX-A 접근성과 반도체 배후 수요는 긍정적이나 외곽 택지 입주가 전세를 누르면 매매 회복은 동탄 핵심권 위주로 제한될 가능성이 큽니다.', + expectation_points_json = '["삼성 화성·기흥 배후 수요","GTX-A 동탄역 접근성"]', + concern_points_json = '["동탄2·봉담 입주 흡수","외곽 택지 전세 민감도"]', + confidence = 0.84, + as_of = '2026-06-25 00:00:00', + published_at = '2026-06-25 05:40:00', + updated_at = '2026-06-25 05:40:00' +where target_id = 'region-gyeonggi-hwaseongsi' + and generated_by = 'codex-quality-rewrite:regional-assessment-20260625'; + +update real_estate_regional_reports +set report_version = 'regional-report-assessment-v5', + prompt_version = 'codex-regional-assessment-diversification-prompt-20260625', + model_name = 'codex-gpt-5-regional-assessment', + headline = '광명시는 뉴타운 신축 전환과 철산·하안 구축 격차를 분리해야 하는 서울 서남권 대체 시장', + summary = '광명시는 서울 서남권 대체 수요와 광명뉴타운 신축 선호가 강하지만, 철산·하안 구축까지 같은 속도로 따라오기는 어려운 시장입니다.', + body = '평가: 광명시는 광명뉴타운 신축 전환이 가격 기준을 끌어올리고, 서울 구로·금천·영등포 접근성이 실수요를 받치는 구조입니다. +전망: 선별 회복 가능성. 신축 입주권과 준신축은 방어력이 있으나 철산·하안 구축은 전세와 거래량이 동행하지 않으면 가격 격차가 더 벌어질 수 있습니다.', + expectation_points_json = '["광명뉴타운 신축 전환","서울 서남권 대체 수요"]', + concern_points_json = '["철산·하안 구축 격차","정비 이주·입주 시차"]', + confidence = 0.84, + as_of = '2026-06-25 00:00:00', + published_at = '2026-06-25 05:40:00', + updated_at = '2026-06-25 05:40:00' +where target_id = 'region-gyeonggi-gwangmyeong' + and generated_by = 'codex-quality-rewrite:regional-assessment-20260625'; + +update real_estate_regional_reports +set report_version = 'regional-report-assessment-v5', + prompt_version = 'codex-regional-assessment-diversification-prompt-20260625', + model_name = 'codex-gpt-5-regional-assessment', + headline = '성남 수정구는 위례 접근성과 원도심 정비 기대가 공존하지만 공급 경쟁을 피하기 어려운 시장', + summary = '성남 수정구는 위례·판교 접근 생활권이라는 장점이 있으나, 위례 공급 경쟁과 원도심 구축 유동성을 함께 봐야 합니다.', + body = '평가: 성남 수정구는 위례와 판교 접근성이 장점이지만, 원도심과 위례 생활권의 수요층이 완전히 같지는 않습니다. +전망: 보수적 선별. 정비 기대가 있는 구간은 관심을 받을 수 있으나 위례 공급 경쟁과 구축 유동성이 약하면 단기 상승은 제한될 가능성이 높습니다.', + expectation_points_json = '["위례·판교 접근 생활권","성남 원도심 정비 기대"]', + concern_points_json = '["위례 공급 경쟁","원도심 구축 유동성"]', + confidence = 0.82, + as_of = '2026-06-25 00:00:00', + published_at = '2026-06-25 05:40:00', + updated_at = '2026-06-25 05:40:00' +where target_id = 'region-gyeonggi-seongnamsisujeonggu' + and generated_by = 'codex-quality-rewrite:regional-assessment-20260625'; + +update real_estate_regional_reports +set report_version = 'regional-report-assessment-v5', + prompt_version = 'codex-regional-assessment-diversification-prompt-20260625', + model_name = 'codex-gpt-5-regional-assessment', + headline = '성남 중원구는 산업 배후와 노후 주거 비중이 맞물려 가격 탄력이 제한되는 시장', + summary = '성남 중원구는 성남하이테크밸리와 모란·상대원 생활권 수요가 있으나, 노후 주거 비중과 분당·판교 대체 한계가 부담입니다.', + body = '평가: 성남 중원구는 성남하이테크밸리 배후 수요와 모란·상대원 상권 접근성이 있지만, 주거 상품성은 분당·판교와 뚜렷하게 갈립니다. +전망: 관망 우위. 저가 실거주 수요는 방어력이 있으나 노후 단지 비중이 높아 전세와 거래량 회복이 확인되기 전까지 상승 탄력은 제한적입니다.', + expectation_points_json = '["성남하이테크밸리 배후","모란·상대원 상권 수요"]', + concern_points_json = '["노후 주거 비중","분당·판교 대체 한계"]', + confidence = 0.82, + as_of = '2026-06-25 00:00:00', + published_at = '2026-06-25 05:40:00', + updated_at = '2026-06-25 05:40:00' +where target_id = 'region-gyeonggi-seongnamsijungwongu' + and generated_by = 'codex-quality-rewrite:regional-assessment-20260625'; + +update real_estate_regional_reports +set report_version = 'regional-report-assessment-v5', + prompt_version = 'codex-regional-assessment-diversification-prompt-20260625', + model_name = 'codex-gpt-5-regional-assessment', + headline = '분당구는 판교 업무 배후와 1기 신도시 정비 기대가 강하지만 고가 선반영 부담이 큰 시장', + summary = '분당구는 판교 업무 배후 전세 수요와 1기 신도시 정비 기대가 핵심이나, 고가 구간은 재건축 일정이 길어질수록 부담이 커집니다.', + body = '평가: 분당구는 판교 업무 배후 수요와 학군·생활 인프라가 강해 성남권 안에서도 가장 방어력이 높은 구간입니다. +전망: 선별 회복 가능성. 다만 고가 구간은 이미 정비 기대가 상당 부분 반영돼 있어 재건축 일정과 전세가 동행하지 않으면 당분간 가격 부담이 남습니다.', + expectation_points_json = '["판교 업무 배후 전세","1기 신도시 정비 기대"]', + concern_points_json = '["고가 구간 선반영","재건축 일정 장기화"]', + confidence = 0.85, + as_of = '2026-06-25 00:00:00', + published_at = '2026-06-25 05:40:00', + updated_at = '2026-06-25 05:40:00' +where target_id = 'region-gyeonggi-seongnamsibundanggu' + and generated_by = 'codex-quality-rewrite:regional-assessment-20260625'; + +update real_estate_regional_reports +set report_version = 'regional-report-assessment-v5', + prompt_version = 'codex-regional-assessment-diversification-prompt-20260625', + model_name = 'codex-gpt-5-regional-assessment', + headline = '성북구는 길음·장위 정비 신축과 외곽 구축의 온도 차가 커지는 동북권 시장', + summary = '성북구는 길음·장위 정비 신축과 동북권 실거주 가격대가 기대 요인이지만, 외곽 구축 거래 둔화와 기대 선반영은 부담입니다.', + body = '평가: 성북구는 길음·장위 정비 신축이 가격 기준을 만들고, 동북권 실거주 가격대가 하방을 받치는 시장입니다. +전망: 선별 관망. 신축·준신축은 방어력이 있으나 외곽 구축은 거래량이 얇아 정비 기대만으로 지속 상승을 설명하기 어렵습니다.', + expectation_points_json = '["길음·장위 정비 신축","동북권 실거주 가격대"]', + concern_points_json = '["외곽 구축 거래 둔화","정비 기대 선반영"]', + confidence = 0.82, + as_of = '2026-06-25 00:00:00', + published_at = '2026-06-25 05:40:00', + updated_at = '2026-06-25 05:40:00' +where target_id = 'region-seoul-seongbuk' + and generated_by = 'codex-quality-rewrite:regional-assessment-20260625'; + +update real_estate_regional_reports +set report_version = 'regional-report-assessment-v5', + prompt_version = 'codex-regional-assessment-diversification-prompt-20260625', + model_name = 'codex-gpt-5-regional-assessment', + headline = '구로구는 G밸리 직주근접 수요가 뚜렷하지만 구축 노후화가 가격 상단을 누르는 시장', + summary = '구로구는 G밸리와 신도림·구로 환승 수요가 실수요를 만들지만, 서남권 구축 노후화와 산업 배후 수요 변동성을 분리해야 합니다.', + body = '평가: 구로구는 G밸리 직주근접과 신도림·구로 환승 수요가 뚜렷해 실거주 기반은 분명합니다. +전망: 관망 우위. 직주근접 수요는 하방을 받치지만 구축 노후화와 산업 배후 수요 변동성이 커서 전세 방어가 확인되기 전까지 상승 지속성은 제한적입니다.', + expectation_points_json = '["G밸리 직주근접 수요","신도림·구로 환승 수요"]', + concern_points_json = '["서남권 구축 노후화","산업 배후 수요 변동"]', + confidence = 0.82, + as_of = '2026-06-25 00:00:00', + published_at = '2026-06-25 05:40:00', + updated_at = '2026-06-25 05:40:00' +where target_id = 'region-seoul-guro' + and generated_by = 'codex-quality-rewrite:regional-assessment-20260625'; + +update real_estate_regional_reports +set report_version = 'regional-report-assessment-v5', + prompt_version = 'codex-regional-assessment-diversification-prompt-20260625', + model_name = 'codex-gpt-5-regional-assessment', + headline = '도봉구는 GTX-C·창동상계 기대가 있으나 착공 전 기대 선반영을 경계해야 하는 시장', + summary = '도봉구는 GTX-C와 창동상계 개발 기대, 중저가 실거주 수요가 장점이나 착공 전 기대 선반영과 노후 단지 전세 약세가 부담입니다.', + body = '평가: 도봉구는 GTX-C와 창동상계 개발 기대가 가장 큰 재평가 논리이고, 서울 안 중저가 실거주 수요가 하방을 지지합니다. +전망: 보수적 관망. 교통·개발 기대가 가격에 먼저 반영될 수 있어 착공·사업 속도와 노후 단지 전세 회복이 확인되기 전까지 추격 매수 논리는 약합니다.', + expectation_points_json = '["GTX-C·창동상계 기대","중저가 실거주 방어"]', + concern_points_json = '["착공 전 기대 선반영","노후 단지 전세 약세"]', + confidence = 0.82, + as_of = '2026-06-25 00:00:00', + published_at = '2026-06-25 05:40:00', + updated_at = '2026-06-25 05:40:00' +where target_id = 'region-seoul-dobong' + and generated_by = 'codex-quality-rewrite:regional-assessment-20260625'; diff --git a/backend/src/main/resources/db/migration/V62__real_estate_regional_point_evidence_sources.sql b/backend/src/main/resources/db/migration/V62__real_estate_regional_point_evidence_sources.sql new file mode 100644 index 0000000..fbe87de --- /dev/null +++ b/backend/src/main/resources/db/migration/V62__real_estate_regional_point_evidence_sources.sql @@ -0,0 +1,258 @@ +insert into real_estate_regional_report_sources ( + id, + report_target_id, + display_order, + ref_type, + ref_id, + label, + title, + url, + source_name, + published_at, + data_status, + created_at, + updated_at +) values + ( + 'regional-report-source-region-gyeonggi-hwaseongsi-point-gtx-a', + 'region-gyeonggi-hwaseongsi', + 11, + 'external', + 'molit-gtx-a-suseo-dongtan-20240321', + '지역 쟁점 근거', + 'GTX-A 수서~동탄, 정기적 이용자 2~3천원대로 이용 가능', + 'https://www.korea.kr/briefing/pressReleaseView.do?newsId=156621217', + '국토교통부', + '2024-03-21 00:00:00', + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-gyeonggi-hwaseongsi-point-samsung', + 'region-gyeonggi-hwaseongsi', + 12, + 'external', + 'samsung-semiconductor-giheung-hwaseong-campus', + '지역 쟁점 근거', + '삼성반도체 45년 사업장의 성장 기록', + 'https://news.samsungsemiconductor.com/kr/%EB%B0%98%EB%8F%84%EC%B2%B4-%EC%97%AD%EC%82%AC%EC%8B%9C%EB%A6%AC%EC%A6%88-6%ED%83%84-%EC%82%BC%EC%84%B1%EB%B0%98%EB%8F%84%EC%B2%B4-45%EB%85%84-%EC%82%AC%EC%97%85%EC%9E%A5%EC%9D%98-%EC%84%B1/', + '삼성전자 반도체 뉴스룸', + null, + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-gyeonggi-gwangmyeong-point-newtown', + 'region-gyeonggi-gwangmyeong', + 11, + 'external', + 'gwangmyeong-newtown-overview', + '지역 쟁점 근거', + '광명뉴타운사업 개발배경·사업개요', + 'https://www.gm.go.kr/pt/partInfo/ud/newtown/gm_newtown/PTMN302.jsp', + '광명시', + null, + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-gyeonggi-gwangmyeong-point-cheolsan-haan', + 'region-gyeonggi-gwangmyeong', + 12, + 'external', + 'gwangmyeong-cheolsan-haan-rebuild', + '지역 쟁점 근거', + '철산·하안택지지구 재건축 사업 추진 현황', + 'https://news.gm.go.kr/bbs/view.html?idxno=5990&sc_category=', + '광명시 뉴스포털', + null, + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-gyeonggi-seongnamsisujeonggu-point-renewal', + 'region-gyeonggi-seongnamsisujeonggu', + 11, + 'external', + 'seongnam-old-town-renewal-support-center', + '지역 쟁점 근거', + '성남시 원도심 도시정비 지원센터 개소', + 'https://www.seongnam.go.kr/city/1000060/30005/bbsView.do?idx=377575%26', + '성남시', + null, + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-gyeonggi-seongnamsisujeonggu-point-wirye', + 'region-gyeonggi-seongnamsisujeonggu', + 12, + 'external', + 'lh-wirye-district-jurisdiction', + '지역 쟁점 근거', + 'LH 서울 위례사업본부 관할구역', + 'https://www.lh.or.kr/user/areaHdofc/comeView.es?mid=a10708020000®_sn=4', + '한국토지주택공사', + null, + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-gyeonggi-seongnamsijungwongu-point-hightech', + 'region-gyeonggi-seongnamsijungwongu', + 11, + 'external', + 'seongnam-hightech-valley-status', + '지역 쟁점 근거', + '성남하이테크밸리 산업단지 현황', + 'https://www.seongnam.go.kr/city/1000310/10191/contents.do', + '성남시', + null, + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-gyeonggi-seongnamsijungwongu-point-renewal', + 'region-gyeonggi-seongnamsijungwongu', + 12, + 'external', + 'seongnam-renewal-project-status', + '지역 쟁점 근거', + '성남시 정비사업현황', + 'https://www.seongnam.go.kr/city/1001100/30564/bbsList.do', + '성남시', + null, + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-gyeonggi-seongnamsibundanggu-point-pangyo', + 'region-gyeonggi-seongnamsibundanggu', + 11, + 'external', + 'pangyo-technovalley-2025-company-status', + '지역 쟁점 근거', + '2025년도 제1·2판교테크노밸리 실태조사 결과', + 'https://www.pangyotechnovalley.org/base/menu/baseView?menuLevel=2&menuNo=20', + '판교테크노밸리', + null, + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-seoul-seongbuk-point-jangwi', + 'region-seoul-seongbuk', + 11, + 'external', + 'seoul-jangwi-13-shintong', + '지역 쟁점 근거', + '성북구 장위동 219-90, 224-12 일대 재개발', + 'https://news.seoul.go.kr/citybuild/archives/537769', + '서울특별시', + null, + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-seoul-seongbuk-point-gileum', + 'region-seoul-seongbuk', + 12, + 'external', + 'seongbuk-gileum-renewal-status', + '지역 쟁점 근거', + '성북구 길음재정비촉진 구역별 현황', + 'https://www.sb.go.kr/www/contents.do?key=6378', + '성북구청', + null, + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-seoul-guro-point-gvalley', + 'region-seoul-guro', + 11, + 'external', + 'seoul-garibong-gvalley-shintong', + '지역 쟁점 근거', + '가리봉동 115일대 신속통합기획 확정, G밸리 직주근접 주거지', + 'https://www.seoul.go.kr/news/news_report.do?nttNo=404186', + '서울특별시', + null, + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-seoul-guro-point-garibong1', + 'region-seoul-guro', + 12, + 'external', + 'seoul-garibong1-redevelopment-20260326', + '지역 쟁점 근거', + '구로구 가리봉1구역 재개발', + 'https://news.seoul.go.kr/citybuild/archives/531600', + '서울특별시', + '2026-03-26 00:00:00', + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-seoul-dobong-point-changdong-sanggye', + 'region-seoul-dobong', + 11, + 'external', + 'seoul-balanced-changdong-sanggye', + '지역 쟁점 근거', + '서울시 지역균형발전계획 창동상계 신경제중심지', + 'https://uri.seoul.go.kr/web/contents/23.do?mid=1083', + '서울균형발전포털', + null, + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ), + ( + 'regional-report-source-region-seoul-dobong-point-gtx-c', + 'region-seoul-dobong', + 12, + 'external', + 'molit-gtx-c-changdong-underground-20230511', + '지역 쟁점 근거', + 'GTX-C 창동역 지하화 한다', + 'https://www.molit.go.kr/LCMS/DWN.jsp?fileName=230511%28%EC%A1%B0%EA%B0%84%29_GTX-C_%EC%B0%BD%EB%8F%99%EC%97%AD_%EC%A7%80%ED%95%98%ED%99%94_%ED%95%9C%EB%8B%A4%28%EC%88%98%EB%8F%84%EA%B6%8C%EA%B4%91%EC%97%AD%EA%B8%89%ED%96%89%EC%B2%A0%EB%8F%84%EA%B3%BC%29.pdf&fold=koreaNews%2Fmobile%2Ffile', + '국토교통부', + '2023-05-11 00:00:00', + 'ok', + '2026-06-25 06:10:00', + '2026-06-25 06:10:00' + ) +on duplicate key update + display_order = values(display_order), + ref_type = values(ref_type), + ref_id = values(ref_id), + label = values(label), + title = values(title), + url = values(url), + source_name = values(source_name), + published_at = values(published_at), + data_status = values(data_status), + updated_at = values(updated_at); + +update real_estate_regional_report_sources +set display_order = 12, + label = '지역 쟁점 근거', + title = '경기도, 분당·일산 기본계획 승인. 1기 신도시 5곳 모두 정비 본격화', + updated_at = '2026-06-25 06:10:00' +where id = 'regional-report-source-region-gyeonggi-seongnamsibundanggu-gyeonggi-06-first-newtown'; diff --git a/backend/src/main/resources/db/migration/V63__user_oauth_accounts.sql b/backend/src/main/resources/db/migration/V63__user_oauth_accounts.sql new file mode 100644 index 0000000..20c8efc --- /dev/null +++ b/backend/src/main/resources/db/migration/V63__user_oauth_accounts.sql @@ -0,0 +1,15 @@ +create table user_oauth_accounts ( + id char(36) not null, + user_id char(36) not null, + provider varchar(40) not null, + provider_user_id varchar(128) not null, + email varchar(255), + display_name varchar(100), + created_at datetime(6) not null, + last_login_at datetime(6) not null, + primary key (id), + constraint fk_user_oauth_accounts_user foreign key (user_id) references app_users (id), + constraint uk_user_oauth_provider_subject unique (provider, provider_user_id) +); + +create index idx_user_oauth_accounts_user_id on user_oauth_accounts (user_id); diff --git a/backend/src/main/resources/db/migration/V64__user_watch_targets.sql b/backend/src/main/resources/db/migration/V64__user_watch_targets.sql new file mode 100644 index 0000000..ec7604a --- /dev/null +++ b/backend/src/main/resources/db/migration/V64__user_watch_targets.sql @@ -0,0 +1,15 @@ +create table user_watch_targets ( + id char(36) not null, + user_id char(36) not null, + target_type varchar(30) not null, + target_id varchar(160) not null, + display_name varchar(120) not null, + landing_path varchar(600) not null, + created_at datetime(6) not null, + updated_at datetime(6) not null, + primary key (id), + constraint fk_user_watch_targets_user foreign key (user_id) references app_users (id), + constraint uk_user_watch_targets_user_target unique (user_id, target_type, target_id) +); + +create index idx_user_watch_targets_user_updated_at on user_watch_targets (user_id, updated_at, created_at); diff --git a/backend/src/main/resources/db/migration/V65__chat_messages.sql b/backend/src/main/resources/db/migration/V65__chat_messages.sql new file mode 100644 index 0000000..8645997 --- /dev/null +++ b/backend/src/main/resources/db/migration/V65__chat_messages.sql @@ -0,0 +1,11 @@ +CREATE TABLE chat_messages ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + author_display_name VARCHAR(20) NOT NULL, + body VARCHAR(500) NOT NULL, + category VARCHAR(20) NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + CONSTRAINT fk_chat_messages_user FOREIGN KEY (user_id) REFERENCES app_users(id) +); + +CREATE INDEX idx_chat_messages_created_at ON chat_messages (created_at DESC, id DESC); diff --git a/backend/src/main/resources/db/migration/V66__chat_message_attachments.sql b/backend/src/main/resources/db/migration/V66__chat_message_attachments.sql new file mode 100644 index 0000000..ab8be25 --- /dev/null +++ b/backend/src/main/resources/db/migration/V66__chat_message_attachments.sql @@ -0,0 +1,2 @@ +ALTER TABLE chat_messages + ADD COLUMN attachment_json TEXT; diff --git a/backend/src/main/resources/db/migration/V67__real_estate_alias_autocomplete_index.sql b/backend/src/main/resources/db/migration/V67__real_estate_alias_autocomplete_index.sql new file mode 100644 index 0000000..9c1739a --- /dev/null +++ b/backend/src/main/resources/db/migration/V67__real_estate_alias_autocomplete_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_real_estate_aliases_autocomplete_match + ON real_estate_aliases (review_state, ambiguous, normalized_alias, target_id); diff --git a/backend/src/main/resources/db/migration/V68__guest_chat_messages.sql b/backend/src/main/resources/db/migration/V68__guest_chat_messages.sql new file mode 100644 index 0000000..e5c47c3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V68__guest_chat_messages.sql @@ -0,0 +1,7 @@ +ALTER TABLE chat_messages + MODIFY user_id VARCHAR(36) NULL; + +ALTER TABLE chat_messages + ADD COLUMN session_id VARCHAR(96) NULL; + +CREATE INDEX idx_chat_messages_session_id ON chat_messages (session_id); diff --git a/backend/src/test/java/com/youbuyfirst/backend/AuthSessionIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/AuthSessionIntegrationTest.java index 9457df8..5d6f5c6 100644 --- a/backend/src/test/java/com/youbuyfirst/backend/AuthSessionIntegrationTest.java +++ b/backend/src/test/java/com/youbuyfirst/backend/AuthSessionIntegrationTest.java @@ -71,6 +71,25 @@ void rejectsCurrentUserWithoutSession() { assertThat(me.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } + @Test + void reportsOAuthProvidersAsUnavailableWhenClientCredentialsAreMissing() throws Exception { + ResponseEntity providers = restTemplate.getForEntity("/api/auth/oauth/providers", String.class); + + assertThat(providers.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(providers.getBody()); + assertThat(body).hasSize(3); + assertThat(body.findValuesAsText("provider")).containsExactly("google", "naver", "kakao"); + assertThat(body.findValuesAsText("displayName")).containsExactly("Google", "Naver", "Kakao"); + assertThat(body.findValues("configured")).allSatisfy(configured -> + assertThat(configured.asBoolean()).isFalse() + ); + assertThat(body.findValuesAsText("authorizationUrl")).containsExactly( + "/oauth2/authorization/google", + "/oauth2/authorization/naver", + "/oauth2/authorization/kakao" + ); + } + @Test void logsInWithPasswordAndInvalidatesSessionOnLogout() throws Exception { restTemplate.postForEntity( diff --git a/backend/src/test/java/com/youbuyfirst/backend/ChatMessageIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/ChatMessageIntegrationTest.java new file mode 100644 index 0000000..c9f1d54 --- /dev/null +++ b/backend/src/test/java/com/youbuyfirst/backend/ChatMessageIntegrationTest.java @@ -0,0 +1,309 @@ +package com.youbuyfirst.backend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class ChatMessageIntegrationTest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @LocalServerPort + private int port; + + @Test + void storesChatMessagesOnTheServerAndReadsThemWithoutThePostingSession() throws Exception { + HttpHeaders authHeaders = new HttpHeaders(); + authHeaders.add(HttpHeaders.COOKIE, sessionCookieHeader(registerUser( + "chatuser01", + "chatuser01@example.com", + "행복" + ))); + + ResponseEntity posted = restTemplate.exchange( + "/api/chat/messages", + HttpMethod.POST, + new HttpEntity<>(Map.of( + "body", "localhost에서 보낸 서버 채팅", + "displayName", "행복한집" + ), authHeaders), + String.class + ); + + assertThat(posted.getStatusCode()).isEqualTo(HttpStatus.CREATED); + JsonNode postedBody = objectMapper.readTree(posted.getBody()); + assertThat(postedBody.path("author").asText()).isEqualTo("행복한집"); + assertThat(postedBody.path("body").asText()).isEqualTo("localhost에서 보낸 서버 채팅"); + assertThat(postedBody.path("category").asText()).isEqualTo("chat"); + assertThat(postedBody.path("mine").asBoolean()).isTrue(); + assertThat(postedBody.path("verified").asBoolean()).isTrue(); + + ResponseEntity readWithoutCookie = restTemplate.getForEntity("/api/chat/messages", String.class); + + assertThat(readWithoutCookie.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode messages = objectMapper.readTree(readWithoutCookie.getBody()); + assertThat(messages).anySatisfy(message -> { + assertThat(message.path("author").asText()).isEqualTo("행복한집"); + assertThat(message.path("body").asText()).isEqualTo("localhost에서 보낸 서버 채팅"); + assertThat(message.path("verified").asBoolean()).isTrue(); + }); + } + + @Test + void storesGuestChatMessagesWithoutLoginAndMarksThemUnverified() throws Exception { + HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/chat/messages")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + "{\"body\":\"비로그인도 채팅 참여\",\"displayName\":\"손님분석가\"}", + StandardCharsets.UTF_8 + )) + .build(); + HttpResponse posted = HttpClient.newHttpClient().send( + request, + HttpResponse.BodyHandlers.ofString() + ); + + assertThat(posted.statusCode()).isEqualTo(HttpStatus.CREATED.value()); + JsonNode postedBody = objectMapper.readTree(posted.body()); + assertThat(postedBody.path("author").asText()).isEqualTo("손님분석가"); + assertThat(postedBody.path("body").asText()).isEqualTo("비로그인도 채팅 참여"); + assertThat(postedBody.path("mine").asBoolean()).isTrue(); + assertThat(postedBody.path("verified").asBoolean()).isFalse(); + + ResponseEntity readWithoutCookie = restTemplate.getForEntity("/api/chat/messages?limit=20", String.class); + + assertThat(readWithoutCookie.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode messages = objectMapper.readTree(readWithoutCookie.getBody()); + assertThat(messages).anySatisfy(message -> { + assertThat(message.path("author").asText()).isEqualTo("손님분석가"); + assertThat(message.path("verified").asBoolean()).isFalse(); + }); + } + + @Test + void rejectsGuestChatDisplayNameAlreadyUsedByRegisteredUser() throws Exception { + registerUser( + "reservedchat01", + "reservedchat01@example.com", + "ReservedBuyer" + ); + + ResponseEntity availability = restTemplate.getForEntity( + "/api/chat/nickname-availability?displayName={displayName}", + String.class, + "ReservedBuyer" + ); + + assertThat(availability.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(objectMapper.readTree(availability.getBody()).path("available").asBoolean()).isFalse(); + + HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/chat/messages")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + "{\"body\":\"guest collision\",\"displayName\":\"ReservedBuyer\"}", + StandardCharsets.UTF_8 + )) + .build(); + HttpResponse posted = HttpClient.newHttpClient().send( + request, + HttpResponse.BodyHandlers.ofString() + ); + + assertThat(posted.statusCode()).isEqualTo(HttpStatus.CONFLICT.value()); + } + + @Test + void storesChatMessageAttachmentsAndReadsThemWithLandingPath() throws Exception { + HttpHeaders authHeaders = new HttpHeaders(); + authHeaders.add(HttpHeaders.COOKIE, sessionCookieHeader(registerUser( + "attachchat01", + "attachchat01@example.com", + "첨부테스터" + ))); + + ResponseEntity posted = restTemplate.exchange( + "/api/chat/messages", + HttpMethod.POST, + new HttpEntity<>(Map.of( + "body", "대전 유성구 리포트 같이 봐요", + "displayName", "첨부테스터", + "attachment", Map.of( + "type", "region", + "targetId", "region-daejeon-yuseong", + "title", "대전 유성구", + "subtitle", "지역 분석 · 최근 1주", + "metricLabel", "최근 1주", + "metricValue", "+0.31%", + "metricTone", "up", + "landingPath", "/realestate/map/region-daejeon?selectedTargetId=region-daejeon-yuseong&selectedRegionCode=25040&period=week" + ) + ), authHeaders), + String.class + ); + + assertThat(posted.getStatusCode()).isEqualTo(HttpStatus.CREATED); + JsonNode postedBody = objectMapper.readTree(posted.getBody()); + assertThat(postedBody.path("attachment").path("type").asText()).isEqualTo("region"); + assertThat(postedBody.path("attachment").path("targetId").asText()).isEqualTo("region-daejeon-yuseong"); + assertThat(postedBody.path("attachment").path("metricValue").asText()).isEqualTo("+0.31%"); + assertThat(postedBody.path("verified").asBoolean()).isTrue(); + assertThat(postedBody.path("attachment").path("landingPath").asText()) + .isEqualTo("/realestate/map/region-daejeon?selectedTargetId=region-daejeon-yuseong&selectedRegionCode=25040&period=week"); + + ResponseEntity readWithoutCookie = restTemplate.getForEntity("/api/chat/messages?limit=20", String.class); + + assertThat(readWithoutCookie.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode messages = objectMapper.readTree(readWithoutCookie.getBody()); + assertThat(messages).anySatisfy(message -> { + assertThat(message.path("body").asText()).isEqualTo("대전 유성구 리포트 같이 봐요"); + assertThat(message.path("attachment").path("title").asText()).isEqualTo("대전 유성구"); + assertThat(message.path("attachment").path("landingPath").asText()).contains("/realestate/map/region-daejeon"); + }); + } + + @Test + void capsRecentChatMessagesAtOneHundredEvenWhenClientRequestsMore() throws Exception { + HttpClient client = HttpClient.newHttpClient(); + for (int i = 1; i <= 105; i++) { + String body = String.format("limit-msg-%03d", i); + HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/chat/messages")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + "{\"body\":\"" + body + "\",\"displayName\":\"LimitGuest\"}", + StandardCharsets.UTF_8 + )) + .build(); + HttpResponse posted = client.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + + assertThat(posted.statusCode()).isEqualTo(HttpStatus.CREATED.value()); + } + + ResponseEntity readWithOversizedLimit = restTemplate.getForEntity( + "/api/chat/messages?limit=500", + String.class + ); + + assertThat(readWithOversizedLimit.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode messages = objectMapper.readTree(readWithOversizedLimit.getBody()); + assertThat(messages).hasSize(100); + assertThat(messages.get(0).path("body").asText()).isEqualTo("limit-msg-006"); + assertThat(messages.get(99).path("body").asText()).isEqualTo("limit-msg-105"); + assertThat(readWithOversizedLimit.getBody()).doesNotContain("limit-msg-001"); + } + + @Test + void streamsCreatedChatMessagesToOpenClientsWithoutRefresh() throws Exception { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest streamRequest = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/chat/messages/stream")) + .GET() + .build(); + + HttpResponse stream = client.send( + streamRequest, + HttpResponse.BodyHandlers.ofInputStream() + ); + assertThat(stream.statusCode()).isEqualTo(HttpStatus.OK.value()); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream.body(), StandardCharsets.UTF_8))) { + String streamedBody = "새로고침 없이 받는 서버 채팅"; + CompletableFuture sawStreamedMessage = CompletableFuture.supplyAsync( + () -> containsSseLine(reader, streamedBody), + executor + ); + HttpHeaders authHeaders = new HttpHeaders(); + authHeaders.add(HttpHeaders.COOKIE, sessionCookieHeader(registerUser( + "streamchat01", + "streamchat01@example.com", + "스트림" + ))); + + ResponseEntity posted = restTemplate.exchange( + "/api/chat/messages", + HttpMethod.POST, + new HttpEntity<>(Map.of( + "body", streamedBody, + "displayName", "스트림" + ), authHeaders), + String.class + ); + + assertThat(posted.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(sawStreamedMessage.get(10, TimeUnit.SECONDS)).isTrue(); + } finally { + executor.shutdownNow(); + } + } + + private ResponseEntity registerUser(String username, String email, String displayName) { + return restTemplate.postForEntity( + "/api/auth/register", + Map.of( + "username", username, + "email", email, + "password", "watch-1234!", + "displayName", displayName + ), + String.class + ); + } + + private static String sessionCookieHeader(ResponseEntity response) { + return response.getHeaders().getOrEmpty(HttpHeaders.SET_COOKIE) + .stream() + .filter(cookie -> cookie.startsWith("JSESSIONID=")) + .findFirst() + .map(cookie -> cookie.split(";", 2)[0]) + .orElseThrow(); + } + + private static boolean containsSseLine(BufferedReader reader, String expected) { + try { + String line; + while ((line = reader.readLine()) != null) { + if (line.contains(expected)) { + return true; + } + } + return false; + } catch (IOException ex) { + throw new CompletionException(ex); + } + } +} diff --git a/backend/src/test/java/com/youbuyfirst/backend/ChatPresenceIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/ChatPresenceIntegrationTest.java new file mode 100644 index 0000000..9af8136 --- /dev/null +++ b/backend/src/test/java/com/youbuyfirst/backend/ChatPresenceIntegrationTest.java @@ -0,0 +1,38 @@ +package com.youbuyfirst.backend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class ChatPresenceIntegrationTest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void returnsActiveChatHeartbeatCountForChatPresence() throws Exception { + ResponseEntity presence = restTemplate.getForEntity("/api/chat/presence", String.class); + + assertThat(presence.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(presence.getHeaders().getOrEmpty(HttpHeaders.SET_COOKIE)) + .anyMatch(cookie -> cookie.startsWith("JSESSIONID=")); + + JsonNode body = objectMapper.readTree(presence.getBody()); + assertThat(body.path("activeSessionCount").asInt()).isGreaterThanOrEqualTo(1); + assertThat(body.path("source").asText()).isEqualTo("chat_presence_heartbeat"); + } +} diff --git a/backend/src/test/java/com/youbuyfirst/backend/RealEstateBatchIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/RealEstateBatchIntegrationTest.java index 1e68620..34f513f 100644 --- a/backend/src/test/java/com/youbuyfirst/backend/RealEstateBatchIntegrationTest.java +++ b/backend/src/test/java/com/youbuyfirst/backend/RealEstateBatchIntegrationTest.java @@ -310,6 +310,19 @@ void marketDataScheduleRefreshJobMaterializesMonthlyCalendarForFrontend() throws """); } + if ("https://www.reb.or.kr/r-one/portal/compose/scheduleStatsPage.do".equals(url)) { + return RealEstateExternalFetchResult.ok(""" + + + + +
+ +
+ """); + } if ("https://www.applyhome.co.kr/ai/aia/selectAPTLttotPblancListView.do".equals(url)) { return RealEstateExternalFetchResult.ok(""" @@ -317,7 +330,19 @@ void marketDataScheduleRefreshJobMaterializesMonthlyCalendarForFrontend() throws - + + +
제주민영분양주택 신제주 동문디이스트 시그니처원Ⅱ 동문건설 주식회사1811-88382026-06-232026-07-03 ~ 2026-07-062026-07-102026-06-232026-06-24 ~ 2026-06-252026-06-30
+ """); + } + if ("https://khug.or.kr/houstar/web/p01/03/p010301.jsp?currentPage=1".equals(url)) { + return RealEstateExternalFetchResult.ok(""" + + + +
+ 2026년 5월 민간아파트 분양가격 동향 + 2026.06.16
"""); @@ -344,6 +369,16 @@ void marketDataScheduleRefreshJobMaterializesMonthlyCalendarForFrontend() throws """); } + if ("https://www.bok.or.kr/portal/stats/statsPublictSchdul/listCldr.do?date=2026-06&menuNo=200775".equals(url)) { + return RealEstateExternalFetchResult.ok(""" + + + + + +
공표일시각대상통계비고
2026-06-2512:002026년 5월 금융기관 가중평균금리
2026-06-266:002026년 6월 소비자동향조사 결과
2026-06-278:002026년 5월 국제수지(잠정)
+ """); + } return RealEstateExternalFetchResult.ok("ok"); }); @@ -380,6 +415,17 @@ void marketDataScheduleRefreshJobMaterializesMonthlyCalendarForFrontend() throws assertThat(rebPublished.path("status").asText()).isEqualTo("공표 확인"); assertThat(rebPublished.path("dataStatus").asText()).isEqualTo("published"); + JsonNode rebSchedule = firstItemWithId(events, "reb-r-one-schedule-7000022-2026-06-30"); + assertThat(rebSchedule).isNotNull(); + assertThat(rebSchedule.path("date").asText()).isEqualTo("2026-06-30"); + assertThat(rebSchedule.path("title").asText()).isEqualTo("부동산거래(주택) 공표 예정"); + assertThat(rebSchedule.path("summary").asText()) + .isEqualTo("R-ONE 통계공표일정 2026-06-30 기준 부동산거래(주택) 공식 공표 일정입니다."); + assertThat(rebSchedule.path("link").asText()) + .isEqualTo("https://www.reb.or.kr/r-one/portal/stat/easyStatPage.do?cateId=7000022"); + assertThat(rebSchedule.path("status").asText()).isEqualTo("공식 일정"); + assertThat(rebSchedule.path("dataStatus").asText()).isEqualTo("scheduled"); + JsonNode molitStatSchedule = firstItemWithId(events, "molit-stat-schedule-32-2026-06-28"); assertThat(molitStatSchedule).isNotNull(); assertThat(molitStatSchedule.path("title").asText()).isEqualTo("미분양주택현황 공표 예정"); @@ -398,17 +444,59 @@ void marketDataScheduleRefreshJobMaterializesMonthlyCalendarForFrontend() throws assertThat(bokSchedule.path("status").asText()).isEqualTo("공식 일정"); assertThat(bokSchedule.path("dataStatus").asText()).isEqualTo("scheduled"); + JsonNode bokWeightedRate = firstItemWithTitle(events, "2026년 5월 금융기관 가중평균금리 공표 예정"); + assertThat(bokWeightedRate).isNotNull(); + assertThat(bokWeightedRate.path("date").asText()).isEqualTo("2026-06-25"); + assertThat(bokWeightedRate.path("category").asText()).isEqualTo("금융"); + assertThat(bokWeightedRate.path("source").asText()).isEqualTo("한국은행 경제통계"); + assertThat(bokWeightedRate.path("summary").asText()) + .isEqualTo("한국은행 월간통계 공표일정 기준 2026-06-25 12:00 공개 예정인 금융·거시 통계입니다."); + assertThat(bokWeightedRate.path("link").asText()) + .isEqualTo("https://www.bok.or.kr/portal/stats/statsPublictSchdul/listCldr.do?date=2026-06&menuNo=200775"); + assertThat(bokWeightedRate.path("status").asText()).isEqualTo("공식 일정"); + assertThat(bokWeightedRate.path("dataStatus").asText()).isEqualTo("scheduled"); + assertThat(firstItemWithTitle(events, "2026년 5월 국제수지(잠정) 공표 예정")).isNull(); + JsonNode applyHomePublished = firstItemWithId(events, "applyhome-published-2026000289"); assertThat(applyHomePublished).isNotNull(); assertThat(applyHomePublished.path("date").asText()).isEqualTo("2026-06-23"); assertThat(applyHomePublished.path("title").asText()).isEqualTo("신제주 동문디이스트 시그니처원Ⅱ 입주자모집공고"); assertThat(applyHomePublished.path("summary").asText()) - .isEqualTo("모집공고일 2026-06-23, 청약기간 2026-07-03~2026-07-06, 당첨자 발표 2026-07-10인 청약Home 공고입니다."); + .isEqualTo("모집공고일 2026-06-23, 청약기간 2026-06-24~2026-06-25, 당첨자 발표 2026-06-30인 청약Home 공고입니다."); assertThat(applyHomePublished.path("link").asText()) .isEqualTo("https://www.applyhome.co.kr/ai/aia/selectAPTLttotPblancDetail.do?houseManageNo=2026000289&pblancNo=2026000289"); assertThat(applyHomePublished.path("status").asText()).isEqualTo("공표 확인"); assertThat(applyHomePublished.path("dataStatus").asText()).isEqualTo("published"); + JsonNode applyHomeStart = firstItemWithId(events, "applyhome-application-start-2026000289-2026-06-24"); + assertThat(applyHomeStart).isNotNull(); + assertThat(applyHomeStart.path("title").asText()).isEqualTo("신제주 동문디이스트 시그니처원Ⅱ 청약 접수 시작"); + assertThat(applyHomeStart.path("link").asText()).isEqualTo(applyHomePublished.path("link").asText()); + assertThat(applyHomeStart.path("dataStatus").asText()).isEqualTo("scheduled"); + + JsonNode applyHomeEnd = firstItemWithId(events, "applyhome-application-end-2026000289-2026-06-25"); + assertThat(applyHomeEnd).isNotNull(); + assertThat(applyHomeEnd.path("title").asText()).isEqualTo("신제주 동문디이스트 시그니처원Ⅱ 청약 접수 마감"); + assertThat(applyHomeEnd.path("link").asText()).isEqualTo(applyHomePublished.path("link").asText()); + assertThat(applyHomeEnd.path("dataStatus").asText()).isEqualTo("scheduled"); + + JsonNode applyHomeWinner = firstItemWithId(events, "applyhome-winner-2026000289-2026-06-30"); + assertThat(applyHomeWinner).isNotNull(); + assertThat(applyHomeWinner.path("title").asText()).isEqualTo("신제주 동문디이스트 시그니처원Ⅱ 당첨자 발표"); + assertThat(applyHomeWinner.path("link").asText()).isEqualTo(applyHomePublished.path("link").asText()); + assertThat(applyHomeWinner.path("dataStatus").asText()).isEqualTo("scheduled"); + + JsonNode hugPublished = firstItemWithId(events, "hug-market-published-37252"); + assertThat(hugPublished).isNotNull(); + assertThat(hugPublished.path("date").asText()).isEqualTo("2026-06-16"); + assertThat(hugPublished.path("title").asText()).isEqualTo("2026년 5월 민간아파트 분양가격 동향"); + assertThat(hugPublished.path("summary").asText()) + .isEqualTo("HUG 주택도시보증공사 분양시장 공표자료: 2026년 5월 민간아파트 분양가격 동향"); + assertThat(hugPublished.path("link").asText()) + .isEqualTo("https://khug.or.kr/houstar/web/p01/03/p010301.jsp?articleId=37252¤tPage=1&mode=S"); + assertThat(hugPublished.path("status").asText()).isEqualTo("공표 확인"); + assertThat(hugPublished.path("dataStatus").asText()).isEqualTo("published"); + JsonNode molitPublished = firstItemWithId(events, "molit-policy-published-95092134"); assertThat(molitPublished).isNotNull(); assertThat(molitPublished.path("date").asText()).isEqualTo("2026-06-18"); @@ -426,8 +514,15 @@ void marketDataScheduleRefreshJobMaterializesMonthlyCalendarForFrontend() throws assertThat(applyHome).isNotNull(); assertThat(applyHome.path("title").asText()).isEqualTo("청약Home"); assertThat(applyHome.path("label").asText()).isEqualTo("청약·분양"); + assertThat(applyHome.path("status").asText()).isEqualTo("확인 완료"); + assertThat(applyHome.path("dataStatus").asText()).isEqualTo("ok"); assertThat(applyHome.path("stale").asBoolean()).isFalse(); assertThat(applyHome.path("lastCheckedAt").asText()).isNotBlank(); + JsonNode bokStat = firstItemWithId(sources, "bok-stat"); + assertThat(bokStat).isNotNull(); + assertThat(bokStat.path("title").asText()).isEqualTo("한국은행 경제통계"); + assertThat(bokStat.path("dataStatus").asText()).isEqualTo("ok"); + assertEveryScheduleHasContextLink(events); verify(batchUpdatePublisher).publish(argThat((RealEstateBatchUpdateEvent event) -> "market-data-schedules".equals(event.topic()) && "2026-06".equals(event.month()) @@ -436,6 +531,165 @@ void marketDataScheduleRefreshJobMaterializesMonthlyCalendarForFrontend() throws )); } + @Test + void marketDataScheduleRefreshJobCollectsPublicSupplyFinanceAndOfficialPriceRows() throws Exception { + when(externalFetchClient.postForm(anyString(), anyMap(), anyMap())) + .thenReturn(RealEstateExternalFetchResult.ok("{\"data\":[]}")); + when(externalFetchClient.fetch(anyString())).thenAnswer(invocation -> { + String url = invocation.getArgument(0, String.class); + if ("https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1027".equals(url)) { + return RealEstateExternalFetchResult.ok(""" + + + + + + + + + + + + + + + + + +
+ + 고양창릉 A-4BL 공공분양주택 입주자모집공고 + 공공분양경기2026.04.152026.04.30공고중
+ + 분양주택 + 분양주택경남2026.04.092026.04.30공고중
+ + 창원시 매입임대 잔여주택 일반매각 선착순 동호지정 공고 + 매입임대경남2026.04.102026.04.30공고중
+ + [정정공고]광주첨단 H-1,2BL 행복주택 예비입주자 모집 + 행복주택광주2026.04.152026.04.30공고중
+ """); + } + if ("https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1026".equals(url)) { + return RealEstateExternalFetchResult.ok("
"); + } + if ("https://housing.seoul.go.kr/site/main/sh/publicLease/07/list".equals(url)) { + return RealEstateExternalFetchResult.ok(""" + + """); + } + if ("https://www.gh.or.kr/gh/announcement-of-salerental001.do".equals(url)) { + return RealEstateExternalFetchResult.ok(""" + + + + + + +
주택광명학온 공공주택 분양 공고주택사업부26.04.17
+ """); + } + if ("https://www.ih.co.kr/main/sale_lease/notice.jsp".equals(url)) { + return RealEstateExternalFetchResult.ok(""" + + """); + } + if ("https://www.ih.co.kr/main/sale_lease/board/house_notice.jsp".equals(url)) { + return RealEstateExternalFetchResult.ok("
    "); + } + if ("https://www.fsc.go.kr/no010101".equals(url)) { + return RealEstateExternalFetchResult.ok(""" + + """); + } + if ("https://www.realtyprice.kr/notice/board/boardListAll.board".equals(url)) { + return RealEstateExternalFetchResult.ok(""" + + + + + + +
    11공동2026년 1월 1일 기준 공동주택가격 결정·공시 및 이의신청2026-04-30
    + """); + } + return RealEstateExternalFetchResult.ok("ok"); + }); + + jobLauncher.run(marketDataScheduleRefreshJob, new JobParametersBuilder(uniqueParameters("schedule-p2")) + .addString("month", "2026-04") + .toJobParameters()); + + ResponseEntity response = restTemplate.getForEntity( + "/api/realestate/market-data-schedules?month=2026-04", + String.class + ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode root = objectMapper.readTree(response.getBody()); + JsonNode events = root.path("scheduleEvents"); + + JsonNode lhSupply = firstItemWithId(events, "lh-apply-published-2026040001-2026-04-15"); + assertThat(lhSupply).isNotNull(); + assertThat(lhSupply.path("title").asText()).isEqualTo("고양창릉 A-4BL 공공분양주택 입주자모집공고"); + assertThat(lhSupply.path("link").asText()) + .isEqualTo("https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?panId=2026040001&ccrCnntSysDsCd=01&uppAisTpCd=05&aisTpCd=05"); + assertThat(lhSupply.path("dataStatus").asText()).isEqualTo("published"); + assertThat(firstItemWithId(events, "lh-apply-published-2015122300020064-2026-04-09")).isNull(); + assertThat(firstItemWithId(events, "lh-apply-published-2015122300020098-2026-04-10")).isNull(); + assertThat(firstItemWithId(events, "lh-apply-published-2015122300020155-2026-04-15")).isNull(); + + JsonNode shSupply = firstItemWithId(events, "sh-housing-published-sh202604-2026-04-16"); + assertThat(shSupply).isNotNull(); + assertThat(shSupply.path("link").asText()) + .isEqualTo("https://housing.seoul.go.kr/site/main/sh/publicLease/07/view?seq=sh202604"); + + JsonNode ghSupply = firstItemWithId(events, "gh-supply-published-2231-2026-04-17"); + assertThat(ghSupply).isNotNull(); + assertThat(ghSupply.path("link").asText()) + .isEqualTo("https://www.gh.or.kr/gh/announcement-of-salerental001.do?articleNo=2231"); + + JsonNode ihSupply = firstItemWithId(events, "ih-supply-published-77-2026-04-18"); + assertThat(ihSupply).isNotNull(); + assertThat(ihSupply.path("link").asText()) + .isEqualTo("https://www.ih.co.kr/main/sale_lease/notice_view.jsp?seq=77"); + + JsonNode fscPolicy = firstItemWithId(events, "fsc-policy-published-86606"); + assertThat(fscPolicy).isNotNull(); + assertThat(fscPolicy.path("category").asText()).isEqualTo("금융"); + assertThat(fscPolicy.path("link").asText()).isEqualTo("https://www.fsc.go.kr/no010101/86606?curPage=1"); + + JsonNode realtyPrice = firstItemWithId(events, "realty-price-published-36"); + assertThat(realtyPrice).isNotNull(); + assertThat(realtyPrice.path("category").asText()).isEqualTo("공시가격"); + assertThat(realtyPrice.path("link").asText()) + .isEqualTo("https://www.realtyprice.kr/notice/board/boardDetailAll.board?seq=36"); + + JsonNode sources = root.path("sourceLinks"); + assertThat(firstItemWithId(sources, "lh-apply")).isNotNull(); + assertThat(firstItemWithId(sources, "sh-housing")).isNotNull(); + assertThat(firstItemWithId(sources, "gh-supply")).isNotNull(); + assertThat(firstItemWithId(sources, "ih-supply")).isNotNull(); + assertThat(firstItemWithId(sources, "fsc-policy")).isNotNull(); + assertThat(firstItemWithId(sources, "realty-price")).isNotNull(); + assertEveryScheduleHasContextLink(events); + } + @Test void manualBatchRunEndpointLaunchesMarketDataScheduleJob() throws Exception { when(externalFetchClient.fetch(anyString())).thenReturn(RealEstateExternalFetchResult.ok("ok")); @@ -461,6 +715,43 @@ void manualBatchRunEndpointLaunchesMarketDataScheduleJob() throws Exception { assertThat(events).isEmpty(); } + @Test + void marketDataScheduleRefreshJobMarksFailedSourcesSeparatelyFromEmptyMonths() throws Exception { + when(externalFetchClient.fetch(anyString())).thenAnswer(invocation -> { + String url = invocation.getArgument(0, String.class); + if ("https://stat.molit.go.kr/".equals(url) + || "https://stat.molit.go.kr/portal/notice/scheduleList.do".equals(url)) { + return RealEstateExternalFetchResult.failed(503, "provider unavailable"); + } + return RealEstateExternalFetchResult.ok("ok"); + }); + + jobLauncher.run(marketDataScheduleRefreshJob, new JobParametersBuilder(uniqueParameters("schedule-source-error")) + .addString("month", "2026-10") + .toJobParameters()); + + ResponseEntity response = restTemplate.getForEntity( + "/api/realestate/market-data-schedules?month=2026-10", + String.class + ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode root = objectMapper.readTree(response.getBody()); + assertThat(root.path("scheduleEvents")).isEmpty(); + + JsonNode sources = root.path("sourceLinks"); + JsonNode molitStat = firstItemWithId(sources, "molit-stat"); + assertThat(molitStat).isNotNull(); + assertThat(molitStat.path("status").asText()).isEqualTo("확인 필요"); + assertThat(molitStat.path("dataStatus").asText()).isEqualTo("error"); + assertThat(molitStat.path("stale").asBoolean()).isTrue(); + + JsonNode applyHome = firstItemWithId(sources, "applyhome"); + assertThat(applyHome).isNotNull(); + assertThat(applyHome.path("status").asText()).isEqualTo("확인 완료"); + assertThat(applyHome.path("dataStatus").asText()).isEqualTo("ok"); + assertThat(applyHome.path("stale").asBoolean()).isFalse(); + } + @Test void marketDataScheduleRefreshJobReplacesMonthWithLatestOfficialRows() throws Exception { AtomicBoolean includeApplyHomeNotice = new AtomicBoolean(true); @@ -615,6 +906,30 @@ private static JsonNode firstItemWithId(JsonNode items, String id) { return null; } + private static void assertEveryScheduleHasContextLink(JsonNode items) { + for (JsonNode item : items) { + String id = item.path("id").asText(); + String link = item.path("link").asText(); + assertThat(link).describedAs(id + " link").isNotBlank(); + assertThat(link).describedAs(id + " context link").matches(".*(" + + "selectBulletinPage\\.do" + + "|easyStatPage\\.do\\?cateId=" + + "|viewChk\\.do" + + "|selectAPTLttotPblancDetail\\.do" + + "|selectWrtancInfo\\.do" + + "|articleId=" + + "|/site/main/sh/publicLease/07/view" + + "|announcement-of-salerental001\\.do\\?articleNo=" + + "|/main/sale_lease/notice_view\\.jsp" + + "|/no010101/" + + "|boardDetailAll\\.board\\?seq=" + + "|/USR/NEWS/.*/dtl\\.jsp" + + "|listYear\\.do\\?menuNo=200755&mtgSe=A" + + "|statsPublictSchdul/listCldr\\.do\\?date=" + + ").*"); + } + } + private static JsonNode firstTargetWithId(JsonNode targets, String targetId) { for (JsonNode target : targets) { if (targetId.equals(target.path("targetId").asText())) { diff --git a/backend/src/test/java/com/youbuyfirst/backend/RealEstatePublicDataRawIngestionIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/RealEstatePublicDataRawIngestionIntegrationTest.java index 363b6d7..d5a1f25 100644 --- a/backend/src/test/java/com/youbuyfirst/backend/RealEstatePublicDataRawIngestionIntegrationTest.java +++ b/backend/src/test/java/com/youbuyfirst/backend/RealEstatePublicDataRawIngestionIntegrationTest.java @@ -21,6 +21,17 @@ class RealEstatePublicDataRawIngestionIntegrationTest { private static final String PROMOTION_LAWD_CODE = "11992"; private static final String PROMOTION_RUN_KEY = "molit_apt_rent:11992:202606"; + private static final List MOLIT_MARKET_DATASETS = List.of( + "molit_apt_trade", + "molit_apt_rent", + "molit_offi_trade", + "molit_offi_rent", + "molit_rh_trade", + "molit_rh_rent", + "molit_sh_trade", + "molit_sh_rent", + "molit_silv_trade" + ); @Autowired private TestRestTemplate restTemplate; @@ -322,7 +333,7 @@ void importedRegionTargetsCanDriveRawIngestionPromotionAndTargetQueries() throws assertThat(targetRows) .filteredOn(item -> "region-daegu-suseong".equals(item.path("targetId").asText())) .extracting(item -> item.path("providerDataset").asText()) - .containsExactlyInAnyOrder("molit_apt_trade", "molit_apt_rent"); + .containsExactlyInAnyOrderElementsOf(MOLIT_MARKET_DATASETS); Map rawItem = new LinkedHashMap<>(); rawItem.put("providerDataset", "molit_apt_trade"); diff --git a/backend/src/test/java/com/youbuyfirst/backend/RealEstateRegionalReportIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/RealEstateRegionalReportIntegrationTest.java new file mode 100644 index 0000000..771f0f3 --- /dev/null +++ b/backend/src/test/java/com/youbuyfirst/backend/RealEstateRegionalReportIntegrationTest.java @@ -0,0 +1,651 @@ +package com.youbuyfirst.backend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class RealEstateRegionalReportIntegrationTest { + + private static final String REGIONAL_ASSESSMENT_GENERATOR = "codex-quality-rewrite:regional-assessment-20260625"; + private static final List FORBIDDEN_POINT_WORDS = List.of( + "합니다", + "됩니다", + "습니다", + "있습니다", + "수 있습니다", + "." + ); + private static final List OUTLOOK_WORDS = List.of("관망", "선별", "회복 가능성", "방어력", "조정"); + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @Order(1) + void servesResearchedLatestReportForEveryRegionAndSigunguTarget() throws Exception { + ResponseEntity mapo = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-seoul-mapo" + ); + ResponseEntity haeundae = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-busan-haeundae" + ); + + assertThat(mapo.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode mapoRoot = objectMapper.readTree(mapo.getBody()); + assertThat(mapoRoot.path("targetId").asText()).isEqualTo("region-seoul-mapo"); + assertThat(mapoRoot.path("regionLevel").asText()).isEqualTo("sigungu"); + assertThat(mapoRoot.path("generatedBy").asText()).isEqualTo(REGIONAL_ASSESSMENT_GENERATOR); + assertThat(mapoRoot.path("title").asText()).isNotBlank(); + assertThat(mapoRoot.path("body").asText()).isNotBlank(); + assertThat(mapoRoot.path("expectationPoints")).hasSizeBetween(1, 2); + assertThat(mapoRoot.path("concernPoints")).hasSizeBetween(1, 2); + assertThat(mapoRoot.path("sources")).hasSizeGreaterThanOrEqualTo(6); + assertThat(mapoRoot.path("sources").get(0).path("title").asText()).isNotBlank(); + + assertThat(haeundae.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode haeundaeRoot = objectMapper.readTree(haeundae.getBody()); + assertThat(haeundaeRoot.path("targetId").asText()).isEqualTo("region-busan-haeundae"); + assertThat(haeundaeRoot.path("regionLevel").asText()).isEqualTo("sigungu"); + assertThat(haeundaeRoot.path("body").asText()).contains("해운대"); + } + + @Test + @Order(2) + void seoulReportsUseDeepResearchSynthesisInsteadOfGenericFacts() throws Exception { + ResponseEntity seoul = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-seoul" + ); + ResponseEntity seocho = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-seoul-seocho" + ); + ResponseEntity mapo = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-seoul-mapo" + ); + ResponseEntity nowon = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-seoul-nowon" + ); + + assertThat(seoul.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode seoulRoot = objectMapper.readTree(seoul.getBody()); + assertThat(seoulRoot.path("generatedBy").asText()).isEqualTo(REGIONAL_ASSESSMENT_GENERATOR); + assertThat(seoulRoot.path("body").asText()).contains("강남3구와 용산"); + assertThat(seoulRoot.path("body").asText()).contains("서리풀2지구 2,000호"); + assertThat(seoulRoot.path("body").asText()).contains("전세 압력"); + + assertThat(seocho.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode seochoRoot = objectMapper.readTree(seocho.getBody()); + assertThat(seochoRoot.path("body").asText()).contains("서리풀2지구"); + assertThat(seochoRoot.path("concernPoints").toString()).contains("토지거래허가"); + + assertThat(mapo.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode mapoRoot = objectMapper.readTree(mapo.getBody()); + assertThat(mapoRoot.path("body").asText()).contains("상암·공덕·홍대"); + assertThat(mapoRoot.path("body").asText()).contains("강남권 대체재"); + + assertThat(nowon.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode nowonRoot = objectMapper.readTree(nowon.getBody()); + assertThat(nowonRoot.path("body").asText()).contains("노후 단지"); + assertThat(nowonRoot.path("body").asText()).contains("재건축 기대와 실수요 가격대"); + } + + @Test + @Order(3) + void gyeonggiAndIncheonReportsUseDeepResearchSynthesis() throws Exception { + ResponseEntity gyeonggi = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-gyeonggi" + ); + ResponseEntity yonginCheoin = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-gyeonggi-yonginsicheoingu" + ); + ResponseEntity bucheon = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-gyeonggi-bucheon" + ); + ResponseEntity incheonGyeyang = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-incheon-gyeyang" + ); + + assertThat(gyeonggi.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode gyeonggiRoot = objectMapper.readTree(gyeonggi.getBody()); + assertThat(gyeonggiRoot.path("generatedBy").asText()) + .isEqualTo(REGIONAL_ASSESSMENT_GENERATOR); + assertThat(gyeonggiRoot.path("body").asText()).contains("1기 신도시 정비"); + assertThat(gyeonggiRoot.path("body").asText()).contains("3기 신도시"); + assertThat(gyeonggiRoot.path("body").asText()).contains("용인 반도체"); + + assertThat(yonginCheoin.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode yonginRoot = objectMapper.readTree(yonginCheoin.getBody()); + assertThat(yonginRoot.path("body").asText()).contains("첨단시스템반도체 국가산단"); + assertThat(yonginRoot.path("concernPoints").toString()).contains("산업 뉴스"); + + assertThat(bucheon.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode bucheonRoot = objectMapper.readTree(bucheon.getBody()); + assertThat(bucheonRoot.path("body").asText()).contains("부천대장"); + assertThat(bucheonRoot.path("body").asText()).contains("중동 1기 신도시"); + + assertThat(incheonGyeyang.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode gyeyangRoot = objectMapper.readTree(incheonGyeyang.getBody()); + assertThat(gyeyangRoot.path("body").asText()).contains("계양테크노밸리"); + assertThat(gyeyangRoot.path("body").asText()).contains("17천호"); + assertThat(gyeyangRoot.path("sources")).hasSizeGreaterThanOrEqualTo(8); + } + + @Test + @Order(4) + void allRegionalReportsUseDeepResearchAfterNationwideBatches() throws Exception { + Integer regionCount = jdbcTemplate.queryForObject( + """ + select count(*) + from real_estate_regions + where region_level in ('sido', 'sigungu') + and source like 'seed:%' + """, + Integer.class + ); + Integer deepReportCount = jdbcTemplate.queryForObject( + """ + select count(*) + from real_estate_regional_reports report + join real_estate_regions region on region.target_id = report.target_id + where report.data_quality = 'deep_researched' + and region.region_level in ('sido', 'sigungu') + and region.source like 'seed:%' + """, + Integer.class + ); + Integer genericReportCount = jdbcTemplate.queryForObject( + "select count(*) from real_estate_regional_reports where generated_by = 'codex-research:regional-report-20260624'", + Integer.class + ); + + ResponseEntity busanHaeundae = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-busan-haeundae" + ); + ResponseEntity daeguSuseong = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-daegu-suseong" + ); + ResponseEntity sejong = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-sejong" + ); + ResponseEntity jeju = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-jeju-jeju" + ); + + assertThat(deepReportCount).isEqualTo(regionCount); + assertThat(genericReportCount).isZero(); + + assertThat(busanHaeundae.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode busanRoot = objectMapper.readTree(busanHaeundae.getBody()); + assertThat(busanRoot.path("generatedBy").asText()).isEqualTo(REGIONAL_ASSESSMENT_GENERATOR); + assertThat(busanRoot.path("body").asText()).contains("북항"); + assertThat(busanRoot.path("body").asText()).contains("해운대"); + + assertThat(daeguSuseong.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode daeguRoot = objectMapper.readTree(daeguSuseong.getBody()); + assertThat(daeguRoot.path("body").asText()).contains("미분양"); + assertThat(daeguRoot.path("body").asText()).contains("수성구"); + + assertThat(sejong.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode sejongRoot = objectMapper.readTree(sejong.getBody()); + assertThat(sejongRoot.path("body").asText()).contains("행정수도"); + assertThat(sejongRoot.path("body").asText()).contains("5생활권"); + + assertThat(jeju.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode jejuRoot = objectMapper.readTree(jeju.getBody()); + assertThat(jejuRoot.path("body").asText()).contains("관광"); + assertThat(jejuRoot.path("body").asText()).contains("주택 통계"); + } + + @Test + @Order(5) + void investorColumnRewriteRemovesBoilerplateAndDifferentiatesLocalReports() throws Exception { + ResponseEntity chungnam = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-chungnam" + ); + ResponseEntity asan = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-chungnam-asan" + ); + ResponseEntity cheonanSeobuk = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-chungnam-cheonansiseobukgu" + ); + ResponseEntity dangjin = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-chungnam-dangjinsi" + ); + + assertThat(chungnam.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(asan.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(cheonanSeobuk.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(dangjin.getStatusCode()).isEqualTo(HttpStatus.OK); + + JsonNode chungnamRoot = objectMapper.readTree(chungnam.getBody()); + JsonNode asanRoot = objectMapper.readTree(asan.getBody()); + JsonNode cheonanRoot = objectMapper.readTree(cheonanSeobuk.getBody()); + JsonNode dangjinRoot = objectMapper.readTree(dangjin.getBody()); + + assertThat(chungnamRoot.path("generatedBy").asText()).isEqualTo(REGIONAL_ASSESSMENT_GENERATOR); + assertThat(chungnamRoot.path("body").asText()).contains("천안·아산"); + assertThat(chungnamRoot.path("body").asText()).contains("서해안 산업축"); + + assertThat(asanRoot.path("body").asText()).contains("아산"); + assertThat(asanRoot.path("body").asText()).contains("천안과 붙어 있는 수요"); + assertThat(cheonanRoot.path("body").asText()).contains("서북구"); + assertThat(cheonanRoot.path("body").asText()).contains("수도권 남부 출퇴근"); + assertThat(dangjinRoot.path("body").asText()).contains("당진"); + assertThat(dangjinRoot.path("body").asText()).contains("제철·항만"); + + assertThat(asanRoot.path("body").asText()).isNotEqualTo(cheonanRoot.path("body").asText()); + assertThat(asanRoot.path("body").asText()).isNotEqualTo(dangjinRoot.path("body").asText()); + + for (JsonNode report : List.of(chungnamRoot, asanRoot, cheonanRoot, dangjinRoot)) { + assertThat(report.path("body").asText()) + .contains("평가:") + .contains("전망:") + .doesNotContain("같은 지역 target") + .doesNotContain("공통 시장 배경") + .doesNotContain("최신본으로 다시 교체") + .doesNotContain("AI API 자동 갱신") + .doesNotContain("투자자는") + .doesNotContain("군는"); + assertThat(OUTLOOK_WORDS.stream().anyMatch(report.path("body").asText()::contains)).isTrue(); + assertThat(report.path("expectationPoints")).hasSizeBetween(1, 2); + assertThat(report.path("concernPoints")).hasSizeBetween(1, 2); + assertNounLikePointStyle(report.path("expectationPoints")); + assertNounLikePointStyle(report.path("concernPoints")); + } + } + + @Test + @Order(6) + void localReportsDoNotReuseProvinceLevelAssessmentTemplates() throws Exception { + List> chungnamRows = jdbcTemplate.queryForList( + """ + select report.target_id, target.display_name, report.expectation_points_json, + report.concern_points_json, report.body + from real_estate_regional_reports report + join real_estate_targets target on target.id = report.target_id + where report.target_id like 'region-chungnam%' + and report.target_id <> 'region-chungnam' + order by report.target_id + """ + ); + + assertThat(chungnamRows).hasSizeGreaterThanOrEqualTo(15); + assertThat(chungnamRows.stream() + .map(row -> (String) row.get("expectation_points_json")) + .distinct() + .count()).isGreaterThanOrEqualTo(13); + assertThat(chungnamRows.stream() + .map(row -> (String) row.get("concern_points_json")) + .distinct() + .count()).isGreaterThanOrEqualTo(13); + + for (Map row : chungnamRows) { + String targetId = (String) row.get("target_id"); + String expectation = (String) row.get("expectation_points_json"); + String body = (String) row.get("body"); + assertThat(body) + .contains("평가:") + .contains("전망:") + .doesNotContain("군는") + .doesNotContain("천안·아산 축과 자체 생활권 수요"); + if (!targetId.contains("asan") && !targetId.contains("cheonansi")) { + assertThat(expectation).doesNotContain("천안·아산 전세 동행"); + } + } + + Map boryeong = rowByTargetId(chungnamRows, "region-chungnam-boryeong"); + assertThat((String) boryeong.get("body")).contains("관광").contains("상주 수요"); + assertThat((String) boryeong.get("expectation_points_json")).contains("해양관광 체류 수요"); + + Map gongju = rowByTargetId(chungnamRows, "region-chungnam-gongju"); + assertThat((String) gongju.get("body")).contains("세종").contains("대학"); + + Map gyeryong = rowByTargetId(chungnamRows, "region-chungnam-gyeryongsi"); + assertThat((String) gyeryong.get("body")).contains("대전").contains("군 주거"); + } + + @Test + @Order(7) + void visibleCapitalAreaReportsDoNotShareGenericPointSets() { + List visibleTargetIds = List.of( + "region-gyeonggi-hwaseongsi", + "region-gyeonggi-gwangmyeong", + "region-gyeonggi-seongnamsisujeonggu", + "region-gyeonggi-seongnamsijungwongu", + "region-gyeonggi-seongnamsibundanggu", + "region-seoul-seongbuk", + "region-seoul-guro", + "region-seoul-dobong" + ); + List> reportRows = jdbcTemplate.queryForList( + """ + select target_id, expectation_points_json, concern_points_json, body + from real_estate_regional_reports + where target_id in (?, ?, ?, ?, ?, ?, ?, ?) + """, + visibleTargetIds.toArray() + ); + + assertThat(reportRows).hasSize(visibleTargetIds.size()); + assertThat(reportRows.stream() + .map(row -> (String) row.get("expectation_points_json")) + .distinct() + .count()).isEqualTo((long) visibleTargetIds.size()); + assertThat(reportRows.stream() + .map(row -> (String) row.get("concern_points_json")) + .distinct() + .count()).isEqualTo((long) visibleTargetIds.size()); + + for (Map row : reportRows) { + String expectation = (String) row.get("expectation_points_json"); + String concern = (String) row.get("concern_points_json"); + String body = (String) row.get("body"); + + assertPointJsonUsesNounLikeStyle(expectation); + assertPointJsonUsesNounLikeStyle(concern); + assertThat(expectation) + .doesNotContain("교통·산업축 실수요 확인") + .doesNotContain("정비사업 일정 구체화"); + assertThat(concern) + .doesNotContain("입주 시점 전세 약세") + .doesNotContain("거래량 없는 신고가"); + assertThat(body) + .contains("평가:") + .contains("전망:"); + } + + assertThat((String) rowByTargetId(reportRows, "region-gyeonggi-hwaseongsi").get("expectation_points_json")) + .contains("GTX-A"); + assertThat((String) rowByTargetId(reportRows, "region-gyeonggi-gwangmyeong").get("expectation_points_json")) + .contains("광명뉴타운"); + assertThat((String) rowByTargetId(reportRows, "region-gyeonggi-seongnamsibundanggu").get("concern_points_json")) + .contains("재건축 일정"); + assertThat((String) rowByTargetId(reportRows, "region-seoul-guro").get("expectation_points_json")) + .contains("G밸리"); + } + + @Test + @Order(8) + void visibleCapitalAreaReportsExposePointEvidenceSources() { + List visibleTargetIds = List.of( + "region-gyeonggi-hwaseongsi", + "region-gyeonggi-gwangmyeong", + "region-gyeonggi-seongnamsisujeonggu", + "region-gyeonggi-seongnamsijungwongu", + "region-gyeonggi-seongnamsibundanggu", + "region-seoul-seongbuk", + "region-seoul-guro", + "region-seoul-dobong" + ); + List> evidenceRows = jdbcTemplate.queryForList( + """ + select report_target_id, ref_id, label, title, url + from real_estate_regional_report_sources + where report_target_id in (?, ?, ?, ?, ?, ?, ?, ?) + and label = '지역 쟁점 근거' + order by report_target_id, display_order + """, + visibleTargetIds.toArray() + ); + + for (String targetId : visibleTargetIds) { + assertThat(evidenceRows.stream() + .filter(row -> targetId.equals(row.get("report_target_id"))) + .count()).isGreaterThanOrEqualTo(2); + } + assertThat(evidenceRows) + .extracting(row -> row.get("ref_id")) + .contains( + "molit-gtx-a-suseo-dongtan-20240321", + "samsung-semiconductor-giheung-hwaseong-campus", + "gwangmyeong-newtown-overview", + "gwangmyeong-cheolsan-haan-rebuild", + "seongnam-hightech-valley-status", + "pangyo-technovalley-2025-company-status", + "gyeonggi-first-newtown-master-plan-20250528", + "seoul-garibong-gvalley-shintong", + "seoul-balanced-changdong-sanggye" + ); + assertThat(evidenceRows) + .allSatisfy(row -> assertThat((String) row.get("url")).startsWith("https://")); + } + + private void assertNounLikePointStyle(JsonNode points) { + for (JsonNode point : points) { + String text = point.asText(); + assertThat(text).isNotBlank(); + assertThat(text.length()).isLessThanOrEqualTo(34); + for (String forbidden : FORBIDDEN_POINT_WORDS) { + assertThat(text).doesNotContain(forbidden); + } + } + } + + private void assertPointJsonUsesNounLikeStyle(String pointJson) { + assertThat(pointJson).isNotBlank(); + for (String forbidden : FORBIDDEN_POINT_WORDS) { + assertThat(pointJson).doesNotContain(forbidden); + } + } + + private Map rowByTargetId(List> rows, String targetId) { + return rows.stream() + .filter(row -> targetId.equals(row.get("target_id"))) + .findFirst() + .orElseThrow(); + } + + @Test + @Order(9) + void deepReportCoversEverySidoAndSigunguRegionRow() { + Integer regionCount = jdbcTemplate.queryForObject( + """ + select count(*) + from real_estate_regions + where region_level in ('sido', 'sigungu') + and source like 'seed:%' + """, + Integer.class + ); + Integer reportCount = jdbcTemplate.queryForObject( + """ + select count(*) + from real_estate_regional_reports report + join real_estate_regions region on region.target_id = report.target_id + where region.region_level in ('sido', 'sigungu') + and region.source like 'seed:%' + """, + Integer.class + ); + Integer deepReportCount = jdbcTemplate.queryForObject( + """ + select count(*) + from real_estate_regional_reports report + join real_estate_regions region on region.target_id = report.target_id + where report.data_quality = 'deep_researched' + and region.region_level in ('sido', 'sigungu') + and region.source like 'seed:%' + """, + Integer.class + ); + + assertThat(reportCount).isEqualTo(regionCount); + assertThat(deepReportCount).isEqualTo(regionCount); + + List> reports = jdbcTemplate.queryForList( + """ + select report.body, report.expectation_points_json, report.concern_points_json + from real_estate_regional_reports report + join real_estate_regions region on region.target_id = report.target_id + where region.region_level in ('sido', 'sigungu') + and region.source like 'seed:%' + """ + ); + assertThat(reports).hasSize(regionCount); + for (Map report : reports) { + assertThat((String) report.get("body")) + .contains("평가:") + .contains("전망:") + .doesNotContain("단기 판단") + .doesNotContain("군는"); + assertPointJsonUsesNounLikeStyle((String) report.get("expectation_points_json")); + assertPointJsonUsesNounLikeStyle((String) report.get("concern_points_json")); + } + } + + @Test + @Order(10) + void upsertsOneLatestReportPerTargetForFutureAiAutomation() throws Exception { + Map firstRequest = Map.of( + "reports", + List.of(Map.ofEntries( + Map.entry("reportId", "regional-report-region-seoul-jongno-ai-20260624"), + Map.entry("targetId", "region-seoul-jongno"), + Map.entry("reportVersion", "regional-report-v1"), + Map.entry("promptVersion", "regional-report-prompt-v1"), + Map.entry("modelName", "gpt-5-mini"), + Map.entry("generatedBy", "ai-api"), + Map.entry("title", "서울 종로구 최신 지역 리포트"), + Map.entry("headline", "정책·관광·업무 수요가 겹치는 도심 관찰 구간"), + Map.entry("summary", "종로구는 도심 업무·관광 수요와 정비 정책 변수를 함께 봐야 하는 지역입니다."), + Map.entry("body", "종로구는 단기 가격 방향보다 도심 수요와 정비 정책의 결합을 확인해야 합니다.\n실거래와 전세 지표가 같은 방향으로 움직이는지 분리해서 봅니다."), + Map.entry("expectationPoints", List.of( + "도심 업무 수요와 교통 접근성은 하방을 줄이는 관찰 근거입니다.", + "정비사업 후보지는 정책 일정이 붙으면 설명력이 커집니다." + )), + Map.entry("concernPoints", List.of( + "관광·상권 이슈는 주거 수요와 바로 같지 않습니다.", + "정비사업 기대가 가격 판단으로 과장될 수 있습니다." + )), + Map.entry("dataQuality", "partial"), + Map.entry("confidence", 0.71), + Map.entry("asOf", "2026-06-24T00:00:00Z"), + Map.entry("publishedAt", "2026-06-24T00:10:00Z"), + Map.entry("sources", List.of(Map.of( + "reportSourceId", "regional-report-source-jongno-policy-1", + "refType", "external", + "refId", "molit-policy-source", + "label", "정책 근거", + "title", "국토교통부 정책 발표 후보", + "url", "https://www.molit.go.kr/", + "sourceName", "국토교통부", + "publishedAt", "2026-06-20T00:00:00Z", + "dataStatus", "candidate" + ))) + )) + ); + Map secondRequest = Map.of( + "reports", + List.of(Map.ofEntries( + Map.entry("reportId", "regional-report-region-seoul-jongno-ai-20260625"), + Map.entry("targetId", "region-seoul-jongno"), + Map.entry("reportVersion", "regional-report-v1"), + Map.entry("generatedBy", "ai-api"), + Map.entry("title", "서울 종로구 최신 지역 리포트 갱신"), + Map.entry("headline", "도심 수요는 유지되지만 근거 승인이 먼저입니다."), + Map.entry("summary", "갱신된 종로구 리포트는 최신 근거만 유지합니다."), + Map.entry("body", "두 번째 적재가 같은 target의 최신 리포트를 대체합니다."), + Map.entry("expectationPoints", List.of("업무·교통 수요는 계속 확인할 근거입니다.")), + Map.entry("concernPoints", List.of("승인 전 뉴스 후보를 결론처럼 쓰지 않습니다.")), + Map.entry("dataQuality", "partial"), + Map.entry("confidence", 0.63), + Map.entry("asOf", "2026-06-25T00:00:00Z"), + Map.entry("publishedAt", "2026-06-25T00:10:00Z"), + Map.entry("sources", List.of()) + )) + ); + + ResponseEntity firstUpsert = restTemplate.postForEntity( + "/internal/realestate/regional-reports", + firstRequest, + String.class + ); + ResponseEntity secondUpsert = restTemplate.postForEntity( + "/internal/realestate/regional-reports", + secondRequest, + String.class + ); + ResponseEntity latest = restTemplate.getForEntity( + "/api/realestate/targets/{targetId}/regional-report", + String.class, + "region-seoul-jongno" + ); + + assertThat(firstUpsert.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode firstRoot = objectMapper.readTree(firstUpsert.getBody()); + assertThat(firstRoot.path("acceptedReports").asInt()).isEqualTo(1); + assertThat(firstRoot.path("acceptedSources").asInt()).isEqualTo(1); + + assertThat(secondUpsert.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode secondRoot = objectMapper.readTree(secondUpsert.getBody()); + assertThat(secondRoot.path("acceptedReports").asInt()).isEqualTo(1); + assertThat(secondRoot.path("acceptedSources").asInt()).isEqualTo(0); + + assertThat(latest.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode latestRoot = objectMapper.readTree(latest.getBody()); + assertThat(latestRoot.path("reportId").asText()).isEqualTo("regional-report-region-seoul-jongno-ai-20260625"); + assertThat(latestRoot.path("headline").asText()).contains("근거 승인이 먼저"); + assertThat(latestRoot.path("body").asText()).contains("최신 리포트를 대체"); + assertThat(latestRoot.path("sources")).isEmpty(); + assertThat(latestRoot.path("confidence").asDouble()).isEqualTo(0.63); + } +} diff --git a/backend/src/test/java/com/youbuyfirst/backend/RealEstateTargetIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/RealEstateTargetIntegrationTest.java index 4d8ca0f..48745e5 100644 --- a/backend/src/test/java/com/youbuyfirst/backend/RealEstateTargetIntegrationTest.java +++ b/backend/src/test/java/com/youbuyfirst/backend/RealEstateTargetIntegrationTest.java @@ -20,6 +20,18 @@ @ActiveProfiles("test") class RealEstateTargetIntegrationTest { + private static final List MOLIT_MARKET_DATASETS = List.of( + "molit_apt_trade", + "molit_apt_rent", + "molit_offi_trade", + "molit_offi_rent", + "molit_rh_trade", + "molit_rh_rent", + "molit_sh_trade", + "molit_sh_rent", + "molit_silv_trade" + ); + @Autowired private TestRestTemplate restTemplate; @@ -55,7 +67,7 @@ void searchesSeededRegionsAndExposesPublicDataCollectionTargets() throws Excepti JsonNode dataTargetItems = objectMapper.readTree(dataTargets.getBody()).path("items"); assertThat(dataTargetItems) .extracting(item -> item.path("providerDataset").asText()) - .contains("molit_apt_trade", "molit_apt_rent"); + .containsAll(MOLIT_MARKET_DATASETS); JsonNode jongnoTarget = null; for (JsonNode item : dataTargetItems) { if ("region-seoul-jongno".equals(item.path("targetId").asText()) @@ -155,7 +167,7 @@ void seedsCanonicalFrontendRouteTargetsInTheTargetRegistry() throws Exception { "마포" ); ResponseEntity dataTargets = restTemplate.getForEntity( - "/internal/realestate/market-data-targets?enabled=true&limit=20", + "/internal/realestate/market-data-targets?enabled=true&limit=500", String.class ); @@ -170,7 +182,7 @@ void seedsCanonicalFrontendRouteTargetsInTheTargetRegistry() throws Exception { assertThat(dataTargetItems) .filteredOn(item -> "region-seoul-mapo".equals(item.path("targetId").asText())) .extracting(item -> item.path("providerDataset").asText()) - .containsExactlyInAnyOrder("molit_apt_trade", "molit_apt_rent"); + .containsExactlyInAnyOrderElementsOf(MOLIT_MARKET_DATASETS); } @Test @@ -246,7 +258,6 @@ void searchesTargetsByApprovedAliases() throws Exception { List.of(Map.ofEntries( Map.entry("targetId", "complex-test-mapo-alias-search"), Map.entry("regionTargetId", "region-seoul-jongno"), - Map.entry("legalDongCode", "1144010100"), Map.entry("jibunAddress", "alias search address"), Map.entry("source", "test:alias-search"), Map.entry("markerDataStatus", "ok"), @@ -297,7 +308,120 @@ void searchesTargetsByApprovedAliases() throws Exception { assertThat(items) .filteredOn(item -> "complex-test-mapo-alias-search".equals(item.path("targetId").asText())) .singleElement() - .satisfies(item -> assertThat(item.path("targetType").asText()).isEqualTo("complex")); + .satisfies(item -> { + assertThat(item.path("targetType").asText()).isEqualTo("complex"); + assertThat(item.path("parentTargetId").asText()).isEqualTo("region-seoul-jongno"); + assertThat(item.path("legalDongCode").asText()).isEqualTo("11110"); + assertThat(item.path("regionCode").asText()).isEqualTo("11010"); + }); + } + + @Test + void autocompleteSearchRequiresTwoCharactersAndCapsLargeLimits() throws Exception { + Map targetRequest = Map.of( + "items", + java.util.stream.IntStream.range(0, 12) + .mapToObj(index -> Map.of( + "targetId", "complex-test-autocomplete-" + index, + "targetType", "complex", + "displayName", "검색테스트단지 " + index, + "slug", "autocomplete-test-" + index, + "reviewState", "approved", + "dataStatus", "ok" + )) + .toList() + ); + + ResponseEntity targetResponse = restTemplate.postForEntity( + "/internal/realestate/targets", + targetRequest, + String.class + ); + ResponseEntity tooShort = restTemplate.getForEntity( + "/api/realestate/targets/search?q={query}&mode=autocomplete&limit=8", + String.class, + "검" + ); + ResponseEntity capped = restTemplate.getForEntity( + "/api/realestate/targets/search?q={query}&mode=autocomplete&limit=500", + String.class, + "검색테스트" + ); + + assertThat(targetResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + assertThat(tooShort.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(objectMapper.readTree(tooShort.getBody()).path("items")).isEmpty(); + + assertThat(capped.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode cappedItems = objectMapper.readTree(capped.getBody()).path("items"); + assertThat(cappedItems).hasSize(8); + assertThat(cappedItems) + .extracting(item -> item.path("targetType").asText()) + .containsOnly("complex"); + } + + @Test + void autocompleteSearchFindsDongRegionsFromParentRegionAndDongTokens() throws Exception { + Map request = Map.of( + "items", + List.of( + Map.ofEntries( + Map.entry("targetId", "region-test-daejeon-token"), + Map.entry("displayName", "Daejeon"), + Map.entry("slug", "test-daejeon-token"), + Map.entry("regionLevel", "sido"), + Map.entry("legalDongCode", "30000"), + Map.entry("regionCode", "30"), + Map.entry("source", "test:token-search") + ), + Map.ofEntries( + Map.entry("targetId", "region-test-daejeon-seogu-token"), + Map.entry("displayName", "Daejeon Seo-gu"), + Map.entry("slug", "test-daejeon-seogu-token"), + Map.entry("regionLevel", "sigungu"), + Map.entry("parentTargetId", "region-test-daejeon-token"), + Map.entry("legalDongCode", "30170"), + Map.entry("regionCode", "30170"), + Map.entry("source", "test:token-search") + ), + Map.ofEntries( + Map.entry("targetId", "region-test-daejeon-seogu-galma-token"), + Map.entry("displayName", "Daejeon Seo-gu Galma-dong"), + Map.entry("slug", "test-daejeon-seogu-galma-token"), + Map.entry("regionLevel", "eupmyeondong"), + Map.entry("parentTargetId", "region-test-daejeon-seogu-token"), + Map.entry("legalDongCode", "3017011100"), + Map.entry("regionCode", "3017011100"), + Map.entry("source", "test:token-search") + ) + ) + ); + + ResponseEntity importResponse = restTemplate.postForEntity( + "/internal/realestate/regions", + request, + String.class + ); + ResponseEntity search = restTemplate.getForEntity( + "/api/realestate/targets/search?q={query}&mode=autocomplete&limit=8", + String.class, + "Daejeon Galma-dong" + ); + + assertThat(importResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(search.getStatusCode()).isEqualTo(HttpStatus.OK); + + JsonNode items = objectMapper.readTree(search.getBody()).path("items"); + assertThat(items) + .filteredOn(item -> "region-test-daejeon-seogu-galma-token".equals(item.path("targetId").asText())) + .singleElement() + .satisfies(item -> { + assertThat(item.path("targetType").asText()).isEqualTo("region"); + assertThat(item.path("regionLevel").asText()).isEqualTo("eupmyeondong"); + assertThat(item.path("parentTargetId").asText()).isEqualTo("region-test-daejeon-seogu-token"); + assertThat(item.path("legalDongCode").asText()).isEqualTo("3017011100"); + }); } @Test @@ -338,7 +462,7 @@ void importsRegionsAndCreatesMolitMarketDataTargetsForSigungu() throws Exception "해운대" ); ResponseEntity dataTargets = restTemplate.getForEntity( - "/internal/realestate/market-data-targets?enabled=true&limit=20", + "/internal/realestate/market-data-targets?enabled=true&limit=500", String.class ); @@ -355,7 +479,7 @@ void importsRegionsAndCreatesMolitMarketDataTargetsForSigungu() throws Exception assertThat(marketTargetItems) .filteredOn(item -> "region-busan-haeundae".equals(item.path("targetId").asText())) .extracting(item -> item.path("providerDataset").asText()) - .containsExactlyInAnyOrder("molit_apt_trade", "molit_apt_rent"); + .containsExactlyInAnyOrderElementsOf(MOLIT_MARKET_DATASETS); } @Test diff --git a/backend/src/test/java/com/youbuyfirst/backend/UserWatchTargetIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/UserWatchTargetIntegrationTest.java new file mode 100644 index 0000000..aa9ab29 --- /dev/null +++ b/backend/src/test/java/com/youbuyfirst/backend/UserWatchTargetIntegrationTest.java @@ -0,0 +1,222 @@ +package com.youbuyfirst.backend; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class UserWatchTargetIntegrationTest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @LocalServerPort + private int port; + + @Test + void requiresLoggedInSessionForUserWatchTargets() throws Exception { + ResponseEntity list = restTemplate.getForEntity("/api/realestate/watch-targets", String.class); + HttpRequest save = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/realestate/watch-targets")) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + """ + { + "targetType": "region", + "targetId": "region-seoul-mapo", + "displayName": "서울 마포구", + "landingPath": "/realestate/map/region-seoul?selectedTargetId=region-seoul-mapo" + } + """, + StandardCharsets.UTF_8 + )) + .build(); + HttpResponse saveResponse = HttpClient.newHttpClient().send( + save, + HttpResponse.BodyHandlers.ofString() + ); + + assertThat(list.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(saveResponse.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + } + + @Test + void savesListsAndRemovesWatchTargetsPerSessionUser() throws Exception { + HttpHeaders firstUserHeaders = sessionHeaders(register( + "watchuser01", + "watch-user-01@example.com", + "Watch User One" + )); + HttpHeaders secondUserHeaders = sessionHeaders(register( + "watchuser02", + "watch-user-02@example.com", + "Watch User Two" + )); + + ResponseEntity savedRegion = restTemplate.exchange( + "/api/realestate/watch-targets", + HttpMethod.POST, + new HttpEntity<>(Map.of( + "targetType", "region", + "targetId", "region-seoul-mapo", + "displayName", "서울 마포구", + "landingPath", "/realestate/map/region-seoul?selectedTargetId=region-seoul-mapo&selectedRegionCode=11140&period=week" + ), firstUserHeaders), + String.class + ); + ResponseEntity savedComplex = restTemplate.exchange( + "/api/realestate/watch-targets", + HttpMethod.POST, + new HttpEntity<>(Map.of( + "targetType", "complex", + "targetId", "complex-mock-raemian-daechi", + "displayName", "래미안대치팰리스", + "landingPath", "/realestate/transactions?region=11680&selected=complex-mock-raemian-daechi" + ), firstUserHeaders), + String.class + ); + + assertThat(savedRegion.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(savedComplex.getStatusCode()).isEqualTo(HttpStatus.CREATED); + JsonNode savedRegionBody = objectMapper.readTree(savedRegion.getBody()); + assertThat(savedRegionBody.path("targetType").asText()).isEqualTo("region"); + assertThat(savedRegionBody.path("targetId").asText()).isEqualTo("region-seoul-mapo"); + assertThat(savedRegionBody.path("landingPath").asText()).contains("/realestate/map/region-seoul"); + + ResponseEntity firstUserList = restTemplate.exchange( + "/api/realestate/watch-targets", + HttpMethod.GET, + new HttpEntity<>(firstUserHeaders), + String.class + ); + ResponseEntity secondUserList = restTemplate.exchange( + "/api/realestate/watch-targets", + HttpMethod.GET, + new HttpEntity<>(secondUserHeaders), + String.class + ); + + JsonNode firstItems = objectMapper.readTree(firstUserList.getBody()).path("items"); + JsonNode secondItems = objectMapper.readTree(secondUserList.getBody()).path("items"); + + assertThat(firstUserList.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(firstItems).hasSize(2); + assertThat(firstItems.findValuesAsText("targetId")).containsExactly( + "complex-mock-raemian-daechi", + "region-seoul-mapo" + ); + assertThat(firstItems.findValuesAsText("displayName")).contains("서울 마포구", "래미안대치팰리스"); + assertThat(secondUserList.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(secondItems).isEmpty(); + + ResponseEntity duplicateRegion = restTemplate.exchange( + "/api/realestate/watch-targets", + HttpMethod.POST, + new HttpEntity<>(Map.of( + "targetType", "region", + "targetId", "region-seoul-mapo", + "displayName", "마포구 지역 리포트", + "landingPath", "/realestate/map/region-seoul?selectedTargetId=region-seoul-mapo&selectedRegionCode=11140&period=month" + ), firstUserHeaders), + String.class + ); + ResponseEntity afterDuplicate = restTemplate.exchange( + "/api/realestate/watch-targets", + HttpMethod.GET, + new HttpEntity<>(firstUserHeaders), + String.class + ); + + assertThat(duplicateRegion.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode afterDuplicateItems = objectMapper.readTree(afterDuplicate.getBody()).path("items"); + assertThat(afterDuplicateItems).hasSize(2); + JsonNode updatedRegion = findByTargetId(afterDuplicateItems, "region-seoul-mapo"); + assertThat(updatedRegion.path("displayName").asText()).isEqualTo("마포구 지역 리포트"); + assertThat(updatedRegion.path("landingPath").asText()).contains("period=month"); + + ResponseEntity removed = restTemplate.exchange( + "/api/realestate/watch-targets?targetType=region&targetId=region-seoul-mapo", + HttpMethod.DELETE, + new HttpEntity<>(firstUserHeaders), + String.class + ); + ResponseEntity afterRemove = restTemplate.exchange( + "/api/realestate/watch-targets", + HttpMethod.GET, + new HttpEntity<>(firstUserHeaders), + String.class + ); + + assertThat(removed.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + JsonNode remainingItems = objectMapper.readTree(afterRemove.getBody()).path("items"); + assertThat(remainingItems).hasSize(1); + assertThat(remainingItems.get(0).path("targetId").asText()).isEqualTo("complex-mock-raemian-daechi"); + } + + private ResponseEntity register(String username, String email, String displayName) { + return restTemplate.postForEntity( + "/api/auth/register", + Map.of( + "username", username, + "email", email, + "password", "watch-1234!", + "displayName", displayName + ), + String.class + ); + } + + private static HttpHeaders sessionHeaders(ResponseEntity response) { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.COOKIE, sessionCookieHeader(response)); + return headers; + } + + private static String sessionCookieHeader(ResponseEntity response) { + return sessionCookieHeaders(response) + .stream() + .map(cookie -> cookie.split(";", 2)[0]) + .findFirst() + .orElseThrow(() -> new AssertionError("No JSESSIONID cookie returned")); + } + + private static List sessionCookieHeaders(ResponseEntity response) { + return response.getHeaders() + .getOrEmpty(HttpHeaders.SET_COOKIE) + .stream() + .filter(cookie -> cookie.startsWith("JSESSIONID=")) + .toList(); + } + + private static JsonNode findByTargetId(JsonNode items, String targetId) { + for (JsonNode item : items) { + if (targetId.equals(item.path("targetId").asText())) { + return item; + } + } + throw new AssertionError("No watch target found for " + targetId); + } +} diff --git a/backend/src/test/java/com/youbuyfirst/backend/auth/AppOidcUserServiceTest.java b/backend/src/test/java/com/youbuyfirst/backend/auth/AppOidcUserServiceTest.java new file mode 100644 index 0000000..54070c6 --- /dev/null +++ b/backend/src/test/java/com/youbuyfirst/backend/auth/AppOidcUserServiceTest.java @@ -0,0 +1,99 @@ +package com.youbuyfirst.backend.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AppOidcUserServiceTest { + + @Test + void mapsGoogleOidcUserToAppPrincipal() { + OAuthAccountService oauthAccountService = mock(OAuthAccountService.class); + Instant now = Instant.parse("2026-06-25T09:00:00Z"); + AppUser appUser = AppUser.oauth( + "googleuser", + "google-user@example.com", + "Google User", + "{noop}oauth", + "google", + now + ); + when(oauthAccountService.findOrCreateUser(new OAuthProviderProfile( + "google", + "google-subject-1", + "google-user@example.com", + "Google User" + ))).thenReturn(appUser); + + OidcIdToken idToken = new OidcIdToken( + "id-token", + now, + now.plusSeconds(300), + Map.of( + "sub", "google-subject-1", + "email", "google-user@example.com", + "name", "Google User" + ) + ); + OidcUser googleUser = new DefaultOidcUser( + List.of(new SimpleGrantedAuthority("OIDC_USER")), + idToken + ); + OAuth2UserService delegate = request -> googleUser; + AppOidcUserService service = new AppOidcUserService(oauthAccountService, delegate); + + OidcUser result = service.loadUser(new OidcUserRequest( + googleRegistration(), + new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token", now, now.plusSeconds(300)), + idToken + )); + + assertThat(result).isInstanceOf(AppUserPrincipal.class); + AppUserPrincipal principal = (AppUserPrincipal) result; + assertThat(principal.getUserId()).isEqualTo(appUser.getId()); + assertThat(principal.getAppUsername()).isEqualTo("googleuser"); + assertThat(principal.getEmail()).isEqualTo("google-user@example.com"); + assertThat(result.getIdToken()).isEqualTo(idToken); + verify(oauthAccountService).findOrCreateUser(new OAuthProviderProfile( + "google", + "google-subject-1", + "google-user@example.com", + "Google User" + )); + } + + private static ClientRegistration googleRegistration() { + return ClientRegistration.withRegistrationId("google") + .clientId("google-client") + .clientSecret("google-secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth") + .tokenUri("https://oauth2.googleapis.com/token") + .jwkSetUri("https://www.googleapis.com/oauth2/v3/certs") + .issuerUri("https://accounts.google.com") + .userInfoUri("https://openidconnect.googleapis.com/v1/userinfo") + .userNameAttributeName("sub") + .scope("openid", "profile", "email") + .clientName("Google") + .build(); + } +} diff --git a/backend/src/test/java/com/youbuyfirst/backend/auth/OAuthAccountServiceIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/auth/OAuthAccountServiceIntegrationTest.java new file mode 100644 index 0000000..1c58867 --- /dev/null +++ b/backend/src/test/java/com/youbuyfirst/backend/auth/OAuthAccountServiceIntegrationTest.java @@ -0,0 +1,61 @@ +package com.youbuyfirst.backend.auth; + +import com.youbuyfirst.backend.auth.dto.RegisterRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class OAuthAccountServiceIntegrationTest { + + @Autowired + private AuthService authService; + + @Autowired + private OAuthAccountService oauthAccountService; + + @Autowired + private OAuthAccountRepository oauthAccountRepository; + + @Test + void createsUserAndProviderIdentityFromSocialProfile() { + AppUser user = oauthAccountService.findOrCreateUser(new OAuthProviderProfile( + "kakao", + "kakao-social-1", + "social-kakao@example.com", + "Kakao Social" + )); + + assertThat(user.getAuthProvider()).isEqualTo("kakao"); + assertThat(user.getEmail()).isEqualTo("social-kakao@example.com"); + assertThat(user.getDisplayName()).isEqualTo("Kakao Social"); + assertThat(oauthAccountRepository.findByProviderAndProviderUserId("kakao", "kakao-social-1")) + .hasValueSatisfying(account -> assertThat(account.getUser().getId()).isEqualTo(user.getId())); + } + + @Test + void linksNewProviderIdentityToExistingEmailAccount() { + AppUser localUser = authService.register(new RegisterRequest( + "oauthlink01", + "oauth-link@example.com", + "watch-1234!", + "OAuth Link" + )); + + AppUser linkedUser = oauthAccountService.findOrCreateUser(new OAuthProviderProfile( + "google", + "google-link-1", + "OAUTH-LINK@example.com", + "Google Link" + )); + + assertThat(linkedUser.getId()).isEqualTo(localUser.getId()); + assertThat(linkedUser.getAuthProvider()).isEqualTo("local"); + assertThat(oauthAccountRepository.findByProviderAndProviderUserId("google", "google-link-1")) + .hasValueSatisfying(account -> assertThat(account.getUser().getId()).isEqualTo(localUser.getId())); + } +} diff --git a/backend/src/test/java/com/youbuyfirst/backend/auth/OAuthClientRegistrationConfigTest.java b/backend/src/test/java/com/youbuyfirst/backend/auth/OAuthClientRegistrationConfigTest.java new file mode 100644 index 0000000..d71eaba --- /dev/null +++ b/backend/src/test/java/com/youbuyfirst/backend/auth/OAuthClientRegistrationConfigTest.java @@ -0,0 +1,31 @@ +package com.youbuyfirst.backend.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; + +import static org.assertj.core.api.Assertions.assertThat; + +class OAuthClientRegistrationConfigTest { + + @Test + void usesConfiguredRedirectBaseUrlForProviderCallbacks() { + MockEnvironment environment = new MockEnvironment() + .withProperty("OAUTH_GOOGLE_CLIENT_ID", "google-client") + .withProperty("OAUTH_GOOGLE_CLIENT_SECRET", "google-secret") + .withProperty( + "APP_AUTH_OAUTH_REDIRECT_BASE_URL", + "https://planner-happy-chair.ngrok-free.dev/" + ); + + ClientRegistrationRepository registrations = new OAuthClientRegistrationConfig() + .clientRegistrationRepository(environment); + ClientRegistration google = registrations.findByRegistrationId("google"); + + assertThat(google).isNotNull(); + assertThat(google.getRedirectUri()).isEqualTo( + "https://planner-happy-chair.ngrok-free.dev/login/oauth2/code/{registrationId}" + ); + } +} diff --git a/backend/src/test/java/com/youbuyfirst/backend/auth/OAuthProviderProfileTest.java b/backend/src/test/java/com/youbuyfirst/backend/auth/OAuthProviderProfileTest.java new file mode 100644 index 0000000..95d37e4 --- /dev/null +++ b/backend/src/test/java/com/youbuyfirst/backend/auth/OAuthProviderProfileTest.java @@ -0,0 +1,63 @@ +package com.youbuyfirst.backend.auth; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class OAuthProviderProfileTest { + + @Test + void extractsGoogleProfile() { + OAuthProviderProfile profile = OAuthProviderProfile.from( + "google", + Map.of( + "sub", "google-subject-1", + "email", "buyer@example.com", + "name", "Google Buyer" + ) + ); + + assertThat(profile.provider()).isEqualTo("google"); + assertThat(profile.providerUserId()).isEqualTo("google-subject-1"); + assertThat(profile.email()).isEqualTo("buyer@example.com"); + assertThat(profile.displayName()).isEqualTo("Google Buyer"); + } + + @Test + void extractsNaverProfileFromResponseWrapper() { + OAuthProviderProfile profile = OAuthProviderProfile.from( + "naver", + Map.of("response", Map.of( + "id", "naver-subject-1", + "email", "buyer@naver.com", + "nickname", "Naver Buyer" + )) + ); + + assertThat(profile.provider()).isEqualTo("naver"); + assertThat(profile.providerUserId()).isEqualTo("naver-subject-1"); + assertThat(profile.email()).isEqualTo("buyer@naver.com"); + assertThat(profile.displayName()).isEqualTo("Naver Buyer"); + } + + @Test + void extractsKakaoProfileFromAccountWrapper() { + OAuthProviderProfile profile = OAuthProviderProfile.from( + "kakao", + Map.of( + "id", 123456789L, + "kakao_account", Map.of( + "email", "buyer@kakao.com", + "profile", Map.of("nickname", "Kakao Buyer") + ) + ) + ); + + assertThat(profile.provider()).isEqualTo("kakao"); + assertThat(profile.providerUserId()).isEqualTo("123456789"); + assertThat(profile.email()).isEqualTo("buyer@kakao.com"); + assertThat(profile.displayName()).isEqualTo("Kakao Buyer"); + } +} diff --git a/backend/src/test/java/com/youbuyfirst/backend/chat/ChatPresenceCounterTest.java b/backend/src/test/java/com/youbuyfirst/backend/chat/ChatPresenceCounterTest.java new file mode 100644 index 0000000..0026936 --- /dev/null +++ b/backend/src/test/java/com/youbuyfirst/backend/chat/ChatPresenceCounterTest.java @@ -0,0 +1,55 @@ +package com.youbuyfirst.backend.chat; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChatPresenceCounterTest { + + @Test + void countsOnlyChatSessionsSeenWithinTheHeartbeatWindow() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-25T00:00:00Z")); + ChatPresenceCounter counter = new ChatPresenceCounter(clock, Duration.ofSeconds(45)); + + assertThat(counter.markPresent("session-a")).isEqualTo(1); + + clock.advance(Duration.ofSeconds(20)); + assertThat(counter.markPresent("session-b")).isEqualTo(2); + + clock.advance(Duration.ofSeconds(26)); + assertThat(counter.activeChatSessionCount()).isEqualTo(1); + } + + private static final class MutableClock extends Clock { + + private Instant instant; + + private MutableClock(Instant instant) { + this.instant = instant; + } + + @Override + public ZoneId getZone() { + return ZoneId.of("UTC"); + } + + @Override + public Clock withZone(ZoneId zone) { + return Clock.fixed(instant, zone); + } + + @Override + public Instant instant() { + return instant; + } + + private void advance(Duration duration) { + instant = instant.plus(duration); + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 2308880..140d2ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,15 @@ services: SPRING_DATASOURCE_PASSWORD: youbuyfirst DATA_GO_SERVICE_KEY: ${DATA_GO_SERVICE_KEY:-} KAKAO_REST_API_KEY: ${KAKAO_REST_API_KEY:-} + OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:-} + OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:-} + OAUTH_NAVER_CLIENT_ID: ${OAUTH_NAVER_CLIENT_ID:-} + OAUTH_NAVER_CLIENT_SECRET: ${OAUTH_NAVER_CLIENT_SECRET:-} + OAUTH_KAKAO_CLIENT_ID: ${OAUTH_KAKAO_CLIENT_ID:-} + OAUTH_KAKAO_CLIENT_SECRET: ${OAUTH_KAKAO_CLIENT_SECRET:-} + APP_AUTH_OAUTH_REDIRECT_BASE_URL: ${APP_AUTH_OAUTH_REDIRECT_BASE_URL:-} + APP_AUTH_OAUTH_SUCCESS_REDIRECT: ${APP_AUTH_OAUTH_SUCCESS_REDIRECT:-/dashboard} + APP_AUTH_OAUTH_FAILURE_REDIRECT: ${APP_AUTH_OAUTH_FAILURE_REDIRECT:-/auth/login?oauth=failed} ports: - "8080:8080" depends_on: diff --git a/docs/current/AUTH_SESSION_MVP.md b/docs/current/AUTH_SESSION_MVP.md index b5ff010..3f254af 100644 --- a/docs/current/AUTH_SESSION_MVP.md +++ b/docs/current/AUTH_SESSION_MVP.md @@ -7,6 +7,7 @@ The first login implementation uses Spring Security with the servlet container's - Browser identity is carried by the `JSESSIONID` cookie. - The backend resolves the session to an `APP_USERS` row. - Login uses the user-facing `username` field, while `email` and `displayName` remain unique account profile fields. +- Google, Naver, and Kakao OAuth login reuse the same session contract after provider authentication succeeds. - Redis is intentionally not used in this MVP. - If the backend runs with more than one instance, use sticky sessions or migrate to Spring Session with Redis before relying on shared login state. @@ -17,7 +18,10 @@ The first login implementation uses Spring Security with the servlet container's | `/api/auth/register` | POST | public | `201` with current user and `JSESSIONID` cookie | | `/api/auth/login` | POST | public | `200` with current user and `JSESSIONID` cookie | | `/api/auth/me` | GET | session | `200` with current user, `401` without session | +| `/api/auth/oauth/providers` | GET | public | Google, Naver, and Kakao OAuth availability for the current backend config | | `/api/auth/logout` | POST | session | `204`, invalidates the session | +| `/oauth2/authorization/{provider}` | GET | public | redirects to Google, Naver, or Kakao when that provider has client credentials | +| `/login/oauth2/code/{provider}` | GET | provider callback | creates or links an `APP_USERS` row, sets `JSESSIONID`, redirects to `/dashboard` | Register request: @@ -60,6 +64,18 @@ Current user response: } ``` +OAuth account handling: + +- Provider identities are stored in `user_oauth_accounts` with unique `(provider, provider_user_id)`. +- First OAuth login creates an `APP_USERS` row when no account with that email exists. +- If a provider email matches an existing local account, the provider identity is linked to that user instead of creating a duplicate. +- Google login is handled through the OIDC user service and is converted to the same `AppUserPrincipal` session shape as Naver and Kakao. +- The frontend checks `/api/auth/oauth/providers` before rendering social OAuth links. Providers without configured credentials are shown as disabled buttons, so local users do not hit Spring's Whitelabel 404 fallback. +- Google and Naver need `client-id` and `client-secret`; Kakao needs the REST API key as `client-id`, and `client-secret` only when Kakao client secret verification is enabled. +- Supported env vars: `OAUTH_GOOGLE_CLIENT_ID`, `OAUTH_GOOGLE_CLIENT_SECRET`, `OAUTH_NAVER_CLIENT_ID`, `OAUTH_NAVER_CLIENT_SECRET`, `OAUTH_KAKAO_CLIENT_ID`, `OAUTH_KAKAO_CLIENT_SECRET`. +- Set `APP_AUTH_OAUTH_REDIRECT_BASE_URL` when OAuth callbacks must return through a public proxy origin. For example, `https://example.ngrok-free.dev` produces `https://example.ngrok-free.dev/login/oauth2/code/google`. +- Local SPA redirects can be set with `APP_AUTH_OAUTH_SUCCESS_REDIRECT=http://127.0.0.1:5173/dashboard` and `APP_AUTH_OAUTH_FAILURE_REDIRECT=http://127.0.0.1:5173/auth/login?oauth=failed`. + ## Session Cookie Default runtime settings live in `backend/src/main/resources/application.yml`. diff --git a/docs/current/FRONT_PLANNING_FIRST_URGENT_TASKS.md b/docs/current/FRONT_PLANNING_FIRST_URGENT_TASKS.md index 37898b8..2b227c8 100644 --- a/docs/current/FRONT_PLANNING_FIRST_URGENT_TASKS.md +++ b/docs/current/FRONT_PLANNING_FIRST_URGENT_TASKS.md @@ -57,7 +57,7 @@ ### 주요 일정 - `/indicators`는 지표 상세가 아니라 일정 캘린더로 운영한다. -- 가격지수, 실거래 공개, 공급/미분양, 금리, 청약, 정책 발표 일정을 provider/source URL과 함께 표시한다. +- 가격지수·거래현황, 공급/미분양, 금리, 청약, 정책·공시가격 발표 일정을 provider/source URL과 함께 표시하고, 실거래 공개는 freshness/status로 분리한다. - 기존 `/indicators/:category`는 북마크 호환 route로 같은 일정 화면을 보여준다. ### 단지/지역 상세 diff --git a/docs/current/REAL_ESTATE_DATA_BUILD_STATUS.md b/docs/current/REAL_ESTATE_DATA_BUILD_STATUS.md index 4a73b0d..6e25793 100644 --- a/docs/current/REAL_ESTATE_DATA_BUILD_STATUS.md +++ b/docs/current/REAL_ESTATE_DATA_BUILD_STATUS.md @@ -144,7 +144,7 @@ - 2026-06-23 R-ONE 월별 아파트 매매가격지수 원표(`reb_rone_monthly_apt_sale_price_index`)는 다시 운영 원천으로 사용합니다. 단, 계산된 전월 대비 변동률 fact를 별도로 저장하지 않고, 지수 원값만 `price_index`로 저장합니다. - 2026-06-23 이전 `reb_rone_regional_price_change` 기반 월간 snapshot은 전환 전 상태입니다. 이후 refresh는 저장된 월간 지수 history가 충분할 때 `month`/`quarter`/`halfYear`/`year` snapshot을 다시 생성합니다. - 2026-06-23 시군구 refresh는 R-ONE 지역 현황 지도 PR row가 아니라 월간 Open API 지수 row와 지역 코드 매핑이 가능한 지역만 공식 지수 snapshot 대상으로 삼습니다. 매핑이 없는 군 단위는 임의값을 만들지 않고 `공식 지수 미공표`/`자료 없음`으로 다루는 것이 현재 기준입니다. -- 2026-06-23 주요 일정 배치는 반복 점검 row와 실제 공표 row를 분리했습니다. 실제 상세 공표 수집은 현재 한국부동산원 R-ONE 보도자료, 국토부 보도자료, 청약Home APT 모집공고까지이며, 한국은행 결정문/의사록, 통계누리 공표 완료 row, HUG/LH/SH/GH 공급 공고, 금융위/금감원/기재부 부동산 금융정책은 추가 연결 대상입니다. 따라서 `/indicators`는 아직 모든 부동산 중요 발표를 싹 모은 상태가 아닙니다. +- 2026-06-24 주요 일정 배치는 반복 점검 row와 실제 공표 row를 분리했습니다. 실제 상세 공표 수집은 한국부동산원 R-ONE 보도자료·통계공표일정, 국토부 보도자료, 청약Home APT 모집공고·접수·당첨 일정, HUG 분양시장 공표자료, LH/SH/GH/iH 공급 공고, 금융위원회 부동산 금융정책 보도자료, 부동산공시가격 알리미 공동주택가격 공지까지 확장했습니다. 한국은행 결정문/의사록, 통계누리 공표 완료 row, 금감원/기재부 부동산 금융정책은 추가 연결 후보입니다. - 2026-06-16 MVP 5개 공개 source 기준 daily refresh를 실행했습니다. 대상은 `PPOMPPU:house`, `DCINSIDE:immovables`, `FMKOREA:realestate`, `CLIEN:park`, `COOK82:freeboard`이며, 로컬 실행 결과 source 5개, 확인 게시글 39건, 신규 저장 9건, reaction observation 127건, reaction snapshot 2건을 생성했습니다. - 2026-06-16 발표/시연용 1주일 백필 모드로 `CRAWLER_IGNORE_WATERMARK=true`, `CRAWLER_LATEST_LOOKBACK_HOURS=168`, `CRAWLER_MAX_PAGES_PER_RUN=30`을 적용해 5개 공개 source를 다시 실행했습니다. 결과는 확인 게시글 1,980건, 신규 수용 915건입니다. `PPOMPPU`와 `DCINSIDE`는 30페이지 한도까지 도달해 아직 더 읽을 수 있고, `FMKOREA`는 430 rate-limit/block 신호로 backoff 처리됐습니다. - 같은 백필 데이터로 `REALESTATE_DAILY_REACTION_WINDOW_MINUTES=10080` 기준 7일 반응 snapshot을 재계산했습니다. 결과는 observation 85건, snapshot 14건이며, `GET /api/realestate/reactions/rankings?type=region&windowMinutes=10080&limit=20` 기준 서울 35건, 대구 14건, 부산 10건, 경기 5건, 인천 5건 등 14개 시도 target이 순수 커뮤니티 반응으로 잡힙니다. diff --git a/docs/current/SSAFY_API_SPEC.html b/docs/current/SSAFY_API_SPEC.html new file mode 100644 index 0000000..df7d802 --- /dev/null +++ b/docs/current/SSAFY_API_SPEC.html @@ -0,0 +1,1086 @@ + + + + + + 너나사 부동산 API 명세서 + + + +
    +
    +
    SSAFY 관통프로젝트 제출 문서
    +

    너나사 부동산 API 명세서

    +

    + 실거래, 전세, 공급, 정책, 뉴스/리포트 이슈를 지역과 생활권 기준으로 연결해 보여주는 + 부동산 인사이트 서비스의 백엔드 API 명세입니다. 문서는 현재 Spring Boot Controller와 DTO 기준으로 작성했습니다. +

    +
    +
    프로젝트YouBuyFirst RealEstate
    +
    작성 기준일2026-06-25
    +
    BackendSpring Boot 3.3, Java 21
    +
    FrontendVue 3, Vite, TypeScript
    +
    +
    + +
    +

    1. 문서 범위

    +

    + 이 명세서는 화면에서 직접 쓰는 공개 API와 데이터 적재/운영 확인을 위한 internal API를 분리해 정리합니다. + 투자 권유나 가격 상승 단정이 아니라, 공식 데이터와 근거 로그를 사용자에게 보여주는 관찰형 서비스 계약을 기준으로 합니다. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    구분포함 내용대표 endpoint
    인증/세션회원가입, 로그인, 현재 사용자, 로그아웃, OAuth provider 상태/api/auth/*
    부동산 공개 API지역/단지 검색, 시장 사실, 지도 layer, 리포트, 근거 로그, 일정, 콘텐츠/api/realestate/*
    채팅 API게스트/회원 채팅 메시지, 접속 수, 닉네임 확인, SSE 스트림/api/chat/*
    Internal API공공데이터 적재, 수집 결과 저장, batch 실행, alias/graph/materialized data 갱신/internal/*, /admin/*
    + +
    + Swagger 확인 경로 +

    + Springdoc 의존성이 포함되어 있어 로컬 서버 실행 후 http://localhost:8080/swagger-ui.html에서 + 자동 생성 명세를 확인할 수 있습니다. OpenAPI JSON 기본 경로는 /v3/api-docs입니다. +

    +
    +
    + +
    +

    2. 공통 규칙

    +
    +
    +
    호스트와 포맷
    + + + + + + + +
    Backend base URLhttp://localhost:8080
    Frontend dev proxyVite가 /api, /oauth2, /login/oauth2를 backend로 proxy
    Content-Typeapplication/json; charset=utf-8
    날짜/시간Instant는 ISO-8601, 월은 YYYY-MM, 날짜는 YYYY-MM-DD
    +
    + +
    +
    인증과 쿠키
    + + + + + + + +
    방식Spring Security HTTP Session
    쿠키JSESSIONID, HttpOnly, SameSite=Lax, timeout 30분
    Frontend 옵션로그인 상태가 필요한 호출은 credentials: "include"
    CSRF현재 코드 기준 disabled
    +
    +
    + + + + + + + + + + + + + + + + + + + +
    상태 코드의미주요 사용처
    200 OK조회/갱신 성공대부분의 GET, 기존 관심 대상 갱신
    201 Created새 리소스 생성회원가입, 새 관심 대상 저장, 채팅 메시지 생성
    202 Accepted비동기 작업 접수좌표 사전 적재 trigger
    204 No Content응답 본문 없음로그아웃, 관심 대상 삭제, 최신 브리핑 없음
    400 Bad Request요청값 검증 실패windowMinutes 범위 오류, Bean Validation 실패
    401 Unauthorized인증 필요/api/auth/me, /api/auth/logout, 관심 대상 API
    404 Not Found대상 없음존재하지 않는 batch job 이름 등
    409 Conflict중복/충돌회원가입 중 username, email, displayName 중복
    + +
    + Internal API 운영 주의 +

    + 현재 SecurityConfig에서 인증 필수로 묶은 endpoint는 /api/auth/me, + /api/auth/logout, /api/realestate/watch-targets 계열입니다. + /internal/*/admin/*는 로컬 개발/운영자 네트워크 전용 API로 취급해야 합니다. +

    +
    +
    + +
    +

    3. 공개 API 요약

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MethodEndpointAuth기능
    POST/api/auth/registerPublic회원가입 후 세션 시작
    POST/api/auth/loginPublicusername/password 로그인
    GET/api/auth/meSession현재 로그인 사용자 조회
    GET/api/auth/oauth/providersPublicOAuth provider 설정 상태와 authorization URL 조회
    POST/api/auth/logoutSession세션 무효화 및 쿠키 삭제
    GET/api/realestate/targets/searchPublic지역/단지 target 검색 또는 autocomplete
    GET/api/realestate/targets/{targetId}/aliasesPublic공개 승인된 target alias 목록
    GET/api/realestate/targets/{targetId}/graphPublic지역, 생활권, 단지 연결 관계 조회
    GET/api/realestate/targets/{targetId}/nearby-complexesPublic지역 주변 단지 marker 목록
    GET/api/realestate/market-factsPublic공식 시장 사실 목록 조회
    GET/api/realestate/targets/{targetId}/market-factsPublictarget별 시장 사실 조회
    GET/api/realestate/dashboard/market-summaryPublic대시보드 시장 요약
    GET/api/realestate/map/layersPublic전국/시군구 지도 layer snapshot
    GET/api/realestate/indicatorsPublic가격지수/전세/거래량 등 지표 overview
    GET/api/realestate/market-data-schedulesPublic월간 공식 데이터 공개 일정
    GET/api/realestate/daily-briefings/latestPublic최신 일일 부동산 브리핑
    GET/api/realestate/newsroomPublic뉴스/리포트/링크 feed
    GET/api/realestate/targets/{targetId}/contentPublictarget별 뉴스/리포트 콘텐츠
    GET/api/realestate/targets/{targetId}/timelinePublic정책/개발/콘텐츠 기반 타임라인
    GET/api/realestate/targets/{targetId}/evidence-logsPublic에이전트 평가 근거 로그
    GET/api/realestate/targets/{targetId}/regional-reportPublic지역 분석 리포트와 출처
    GET/api/realestate/reactions/rankingsPublic보조 공개 반응 ranking
    GET/api/realestate/targets/{targetId}/reaction-snapshotPublictarget 반응 snapshot
    GET/api/realestate/targets/{targetId}/reaction-graphPublictarget 연결 관계와 반응 snapshot 조합
    POST/api/realestate/geocodePublic주소/단지명 좌표 해석
    POST/api/realestate/geocode/preloadPublic좌표 캐시 사전 적재 비동기 trigger
    SSE/api/realestate/batch-updates/streamPublicbatch/update event stream
    GET/api/realestate/watch-targetsSession내 관심 지역/단지 목록
    POST/api/realestate/watch-targetsSession관심 지역/단지 저장
    DEL/api/realestate/watch-targetsSession관심 지역/단지 삭제
    GET/api/chat/presencePublic채팅 접속 session 수
    GET/api/chat/nickname-availabilityPublic채팅 닉네임 사용 가능 여부
    GET/api/chat/messagesPublic최근 채팅 메시지
    SSE/api/chat/messages/streamPublic채팅 메시지 실시간 stream
    POST/api/chat/messagesPublic회원/게스트 채팅 메시지 등록
    +
    + +
    +

    4. 인증 API

    +
    +
    POST /api/auth/register
    +

    새 사용자를 만들고 바로 세션을 시작합니다. 성공 시 JSESSIONID 쿠키가 내려갑니다.

    + + + + + + + +
    Request bodyusername, email, password, displayName
    Validationusername: 영문/숫자 4-20자, password: 8-100자이며 영문/숫자/특수문자 포함, displayName: 2-20자
    Response201 Created, CurrentUserResponse
    Error400 validation 실패, 409 username/email/displayName 중복
    +
    {
    +  "username": "ssafy2026",
    +  "email": "ssafy@example.com",
    +  "password": "Passw0rd!",
    +  "displayName": "싸피유저"
    +}
    +
    + +
    +
    POST /api/auth/login
    + + + + + + +
    Request bodyusername, password
    Response200 OK, CurrentUserResponse
    Error401 Unauthorized when credential mismatch
    +
    + +
    +
    GET /api/auth/me Session required
    +

    현재 세션 사용자를 반환하고 마지막 접속 시각을 갱신합니다.

    +
    {
    +  "id": "user-01",
    +  "username": "ssafy2026",
    +  "email": "ssafy@example.com",
    +  "displayName": "싸피유저",
    +  "authProvider": "local",
    +  "createdAt": "2026-06-25T09:00:00Z",
    +  "lastSeenAt": "2026-06-25T09:10:00Z"
    +}
    +
    + +
    +
    GET /api/auth/oauth/providers
    +

    OAuth provider별 설정 상태를 반환합니다. 실제 OAuth 진입은 응답의 authorizationUrl을 사용합니다.

    + + + + + +
    Response itemprovider, displayName, authorizationUrl, configured
    Related routeSpring Security OAuth2 기본 route: /oauth2/authorization/{provider}, callback: /login/oauth2/code/{provider}
    +
    + +
    +
    POST /api/auth/logout Session required
    +

    SecurityContext를 지우고 서버 세션과 JSESSIONID 쿠키를 만료합니다. 응답은 204 No Content입니다.

    +
    +
    + +
    +

    5. 부동산 공개 API 상세

    + +

    5.1 Target 검색/관심 대상

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MethodEndpointQuery/BodyResponse
    GET/api/realestate/targets/searchq?, limit=20, mode=default|autocompleteRealEstateTargetListResponse
    GET/api/realestate/targets/{targetId}/aliasespath: targetIdRealEstateAliasListResponse
    GET/api/realestate/targets/{targetId}/graphdirection=both|in|out, edgeType?, limit=200RealEstateTargetEdgeGraphResponse
    GET/api/realestate/watch-targetsSession cookieUserWatchTargetListResponse
    POST/api/realestate/watch-targetsUserWatchTargetRequest201 Created or 200 OK, UserWatchTargetResponse
    DEL/api/realestate/watch-targetstargetType, targetId, session cookie204 No Content
    + +
    GET /api/realestate/targets/search?q=분당&mode=autocomplete&limit=5
    +
    +{
    +  "items": [
    +    {
    +      "targetId": "region-gyeonggi-seongnamsibundanggu",
    +      "targetType": "region",
    +      "displayName": "경기 성남시 분당구",
    +      "slug": "gyeonggi-seongnamsibundanggu",
    +      "reviewState": "approved",
    +      "dataStatus": "ok",
    +      "regionLevel": "sigungu",
    +      "parentTargetId": "region-gyeonggi",
    +      "legalDongCode": null,
    +      "regionCode": "41135"
    +    }
    +  ]
    +}
    + +

    5.2 시장 사실, 지도, 지표

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MethodEndpointQueryResponse
    GET/api/realestate/market-factstargetId?, legalDongCode?, factType?, dealYm?, limit=100, page=0RealEstateMarketFactListResponse
    GET/api/realestate/targets/{targetId}/market-factsfactType?, limit=100, officialOnly=falseRealEstateMarketFactListResponse
    GET/api/realestate/dashboard/market-summarylegalDongCode?RealEstateMarketSummaryResponse
    GET/api/realestate/map/layerslayerType=sido|sigungu, parentTargetId?RealEstateMapLayerResponse
    GET/api/realestate/indicatorslegalDongCode?, period?RealEstateIndicatorOverviewResponse
    GET/api/realestate/market-data-schedulesmonth? in YYYY-MM, default current monthMarketDataScheduleResponse
    + +
    GET /api/realestate/market-facts?legalDongCode=11680&factType=apt_trade&limit=2
    +
    +{
    +  "items": [
    +    {
    +      "targetType": "complex",
    +      "targetId": "complex-molit-11680-4474",
    +      "factType": "apt_trade",
    +      "provider": "MOLIT",
    +      "providerDataset": "apt_trade",
    +      "legalDongCode": "11680",
    +      "observedAt": "2026-05-01",
    +      "asOf": "2026-06-01",
    +      "valueJson": {
    +        "dealAmount": 152000,
    +        "exclusiveArea": 84.93,
    +        "floor": 12
    +      },
    +      "dataStatus": "ok",
    +      "stale": false
    +    }
    +  ]
    +}
    + +

    5.3 콘텐츠, 일정, 리포트, 근거 로그

    + + + + + + + + + + + + + + + + + + +
    MethodEndpointQueryResponse
    GET/api/realestate/newsroomfeed=all|news|report|link|video, page=1, pageSize=15RealEstateContentListResponse
    GET/api/realestate/targets/{targetId}/contentfeed=all, limit=50RealEstateContentListResponse
    GET/api/realestate/daily-briefings/latest없음200 with RealEstateDailyBriefingResponse or 204
    GET/api/realestate/targets/{targetId}/timelineeventType?, limit=50TimelineEventListResponse
    GET/api/realestate/targets/{targetId}/evidence-logslimit=10RealEstateEvidenceLogListResponse
    GET/api/realestate/targets/{targetId}/regional-reportpath: targetIdRealEstateRegionalReportResponse
    GET/api/realestate/targets/{targetId}/nearby-complexeslimit=20RealEstateNearbyComplexListResponse
    + +
    GET /api/realestate/targets/region-gyeonggi-seongnamsibundanggu/regional-report
    +
    +{
    +  "reportId": "report-2026-06-bundang",
    +  "targetId": "region-gyeonggi-seongnamsibundanggu",
    +  "targetName": "경기 성남시 분당구",
    +  "title": "분당구 지역 흐름 점검",
    +  "headline": "거래와 전세, 공급 일정을 함께 확인해야 하는 지역",
    +  "summary": "공식 거래와 리포트 근거를 함께 요약합니다.",
    +  "expectationPoints": ["교통/정비사업 이슈 확인 필요"],
    +  "concernPoints": ["전세 변동성과 공개 지연 확인 필요"],
    +  "dataQuality": "ok",
    +  "confidence": 0.78,
    +  "asOf": "2026-06-25T00:00:00Z",
    +  "sources": [
    +    {
    +      "reportSourceId": "src-01",
    +      "refType": "market_fact",
    +      "refId": "fact-apt-trade-01",
    +      "label": "실거래 근거",
    +      "title": "MOLIT 아파트 매매 실거래",
    +      "url": "https://...",
    +      "sourceName": "MOLIT",
    +      "dataStatus": "ok"
    +    }
    +  ]
    +}
    + +

    5.4 반응 지표와 보조 그래프

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MethodEndpointQueryResponse
    GET/api/realestate/reactions/rankingstype=region, windowStart?, windowMinutes=60, limit=20, parentTargetId?RealEstateReactionRankingResponse
    GET/api/realestate/targets/{targetId}/reaction-snapshotwindowStart?, windowMinutes=60RealEstateReactionSnapshotDetailResponse
    GET/api/realestate/targets/{targetId}/reaction-graphdirection=out, edgeType?, windowStart?, windowMinutes=60, limit=50RealEstateReactionGraphResponse
    +

    + windowMinutes 허용 범위는 1-10080분입니다. 커뮤니티/공개 반응은 서비스 결론이 아니라 보조 관찰 신호입니다. +

    + +

    5.5 좌표, Batch stream, 채팅

    + + + + + + + + + + + + + + + + + + + +
    MethodEndpointQuery/BodyResponse
    POST/api/realestate/geocode{ "queries": ["서울 강남구 ..."] }{ "results": [...] }
    POST/api/realestate/geocode/preload없음202 Accepted
    SSE/api/realestate/batch-updates/streamEventSourcetext/event-stream
    GET/api/chat/presencesession cookie optionalChatPresenceResponse
    GET/api/chat/nickname-availabilitydisplayName{ "available": true }
    GET/api/chat/messageslimit=50List<ChatMessageResponse>
    SSE/api/chat/messages/streamEventSourcetext/event-stream
    POST/api/chat/messagesbody, displayName, attachment?201 Created, ChatMessageResponse
    + +
    POST /api/chat/messages
    +
    +{
    +  "body": "분당구 리포트 근거 다시 볼게요.",
    +  "displayName": "부동산탐색러",
    +  "attachment": {
    +    "type": "regional-report",
    +    "targetId": "region-gyeonggi-seongnamsibundanggu",
    +    "title": "분당구 지역 리포트",
    +    "landingPath": "/realestate/region-gyeonggi-seongnamsibundanggu"
    +  }
    +}
    +
    + +
    +

    6. Internal/Admin API 요약

    +

    + 아래 API는 Python pipeline, batch job, 운영 확인 화면이 사용하는 내부 계약입니다. + 화면 사용자에게 직접 노출되는 기능보다 데이터 적재, 정규화, 검증, 운영 확인이 목적입니다. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MethodEndpointRequest/Query기능
    GET/internal/realestate/market-data-targetsenabled?, limit=100, page=0수집 대상 provider/lawdCode 목록
    GET/internal/realestate/regionsregionLevel?, limit=100, page=0region target export
    POST/internal/realestate/targetsRealEstateTargetUpsertBatchRequesttarget upsert
    POST/internal/realestate/regionsRealEstateRegionImportBatchRequestregion import 및 market target 보장
    POST/internal/realestate/aliasesRealEstateAliasBatchRequestalias upsert
    POST/internal/realestate/aliases/candidatesRealEstateAliasBatchRequestalias 후보 upsert
    GET/internal/realestate/aliasestargetType?, reviewState?, ambiguous?, limit=500, page=0alias export/review
    POST/internal/realestate/target-edgesRealEstateTargetEdgeBatchRequesttarget graph edge upsert
    GET/internal/realestate/target-edgestargetId?, direction, edgeType?, reviewState?edge export/review
    POST/internal/realestate/market-factsRealEstateMarketFactBatchRequest시장 사실 upsert
    POST/internal/realestate/public-data/raw-ingestionsRealEstatePublicDataRawIngestionBatchRequest공공데이터 raw/staging 적재
    POST/internal/realestate/public-data/promote-stagingproviderDataset, runKey, validationStatus, limit?staging을 market fact로 승격
    GET/internal/realestate/public-data/import-runsproviderDataset?, status?, runKey?, limit=100공공데이터 import run 조회
    POST/internal/realestate/map/layer-snapshots/refreshlayerType, periods, asOf?지도 layer snapshot 재계산
    POST/internal/realestate/content-itemsRealEstateContentBatchRequest뉴스/리포트/링크 콘텐츠 적재
    GET/internal/realestate/targets/{targetId}/contentfeed, limit, reviewState, linkTypetarget 콘텐츠 내부 조회
    POST/internal/realestate/daily-briefingsRealEstateDailyBriefingBatchRequest일일 브리핑 upsert
    POST/internal/realestate/regional-reportsRealEstateRegionalReportBatchRequest지역 리포트와 source upsert
    POST/internal/realestate/evidence-logsRealEstateEvidenceLogBatchRequest에이전트 근거 로그 upsert
    POST/internal/realestate/policy-eventsPolicyEventBatchRequest정책 이벤트와 timeline materialize
    POST/internal/realestate/complexesRealEstateComplexBatchRequest단지 좌표/마커 정보 upsert
    POST/internal/realestate/reaction-snapshotsRealEstateReactionSnapshotBatchRequest보조 반응 snapshot upsert
    POST/internal/realestate/batch-jobs/{jobName}/runmonth?, asOf?Spring Batch 수동 실행
    POST/internal/ingestions/community-postsIngestionRequest공개 게시글 제한 원문 저장
    GET/internal/ingestions/community-posts/exportsource?, publishedFrom, publishedTo, limit, page게시글 분석용 export
    POST/internal/ingestions/crawl-runsCrawlRunReportRequestcrawl run 결과 기록
    POST/internal/crawl-targets/claimCrawlTargetClaimRequestworker가 수집 target claim
    POST/internal/crawl-targets/{targetId}/completeCrawlTargetCompletionRequest수집 target 완료 기록
    GET/internal/crawl-watermarkssource, boardIdboard별 마지막 수집 기준점
    GET/admin/crawl-runslimit=20crawl run 운영 조회
    GET/admin/crawl-targetslimit=50crawl target 운영 조회
    GET/admin/postssource?, limit=50저장 게시글 운영 조회
    GET/admin/diffusion-eventssource?, limit=50확산 이벤트 운영 조회
    GET/admin/comment-collection-targetssource?, limit=50댓글 수집 후보 운영 조회
    + +

    Internal 요청 예시

    +
    POST /internal/realestate/market-facts
    +
    +{
    +  "items": [
    +    {
    +      "targetType": "region",
    +      "targetId": "region-seoul-gangnam",
    +      "factType": "apt_trade",
    +      "provider": "MOLIT",
    +      "providerDataset": "apt_trade",
    +      "providerObjectId": "molit-apt-trade-001",
    +      "legalDongCode": "11680",
    +      "observedAt": "2026-05-01",
    +      "asOf": "2026-06-01",
    +      "valueJson": {
    +        "dealAmount": 152000,
    +        "exclusiveArea": 84.93
    +      },
    +      "dataStatus": "ok",
    +      "stale": false
    +    }
    +  ]
    +}
    +
    + +
    +

    7. 주요 Schema 사전

    +
    +
    + CurrentUserResponse + id, username, email, displayName, authProvider, createdAt, lastSeenAt +
    +
    + RealEstateTargetResponse + targetId, targetType, displayName, slug, reviewState, dataStatus, regionLevel, parentTargetId, legalDongCode, regionCode +
    +
    + UserWatchTargetRequest + targetType, targetId, displayName, landingPath +
    +
    + RealEstateMarketFactResponse + targetType, targetId, factType, provider, providerDataset, providerObjectId, legalDongCode, observedAt, asOf, ingestedAt, sourceUpdatedAt, valueJson, dataStatus, stale +
    +
    + RealEstateMapLayerResponse + layerType, parentTargetId, parentRegionCode, asOf, sourceLabel, mapDataSource, dataStatus, stale, periods, targets +
    +
    + RealEstateMapLayerTargetResponse + targetId, targetType, displayName, slug, regionLevel, regionCode, legalDongCode, parentTargetId, geometryId, periods +
    +
    + RealEstateContentItemResponse + contentId, sourceId, contentType, title, snippet, url, domain, publishedAt, metricLabel, statusLabel, ingestedAt, dataStatus, targetId, linkType, confidence, reviewState +
    +
    + RealEstateRegionalReportResponse + reportId, targetId, targetName, regionLevel, regionCode, reportVersion, promptVersion, modelName, generatedBy, title, headline, summary, body, expectationPoints, concernPoints, dataQuality, confidence, asOf, publishedAt, sources +
    +
    + RealEstateEvidenceLogResponse + evidenceLogId, targetId, snapshotId, evaluationVersion, promptVersion, modelName, tone, summary, subtitle, caveats, dataQuality, confidence, skipReason, evaluatedAt, asOf, evidenceItems +
    +
    + MarketDataScheduleEventResponse + id, date, title, category, source, summary, link, tone, provider, status, dataStatus, stale, lastCheckedAt, asOf +
    +
    + RealEstateReactionSnapshotDetailResponse + targetId, targetType, displayName, window, windowStart, windowEnd, mentionCount, previousMentionCount, mentionDeltaPct, dominantDirection, reactionDirectionRatio, heatScore, quality, freshness, issueMix +
    +
    + ChatMessageResponse + id, author, badge, body, timeLabel, category, mine, tone, verified, createdAt, attachment +
    +
    + +

    공통 데이터 상태 필드

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    필드의미표현 원칙
    provider데이터 제공자. 예: MOLIT, REB, KB, HF 등출처가 확인되지 않으면 임의로 채우지 않음
    providerDatasetprovider 내부 dataset 구분. 예: apt_trade, apt_rent수집/검증/재처리에 쓰이는 key
    asOf사용자에게 보여줄 기준 시점실시간처럼 보이지 않게 화면에 함께 표시
    stale정해진 freshness 기준보다 오래된 데이터 여부true이면 요약/판단에서 제한적으로 사용
    dataStatusok, partial, unknown, mock 등 데이터 상태확인되지 않은 값은 추측하지 않고 상태로 분리
    confidencealias, graph, 반응 snapshot, 평가 근거의 신뢰도결론 강도가 아니라 근거 품질 보조값
    +
    + +
    +

    8. 대표 사용자 흐름

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    흐름호출 순서화면 효과
    대시보드 진입/api/auth/me optional 확인 후 /api/realestate/daily-briefings/latest, /api/realestate/map/layers, /api/realestate/dashboard/market-summary, /api/realestate/newsroom오늘 브리핑, 지도 요약, 시장 요약, 뉴스/리포트 feed를 한 화면에 구성
    지역 상세 확인/targets/{targetId}/regional-report, /evidence-logs, /content, /timeline, /nearby-complexes지역 분석 문장과 그 문장의 출처, 뉴스/정책/단지 근거를 함께 표시
    실거래 탐색/api/realestate/market-facts, /api/realestate/targets/{targetId}/market-facts, 필요 시 /geocode거래 유형, 지역, 기간, 면적/가격 조건에 맞는 공식 시장 사실과 좌표를 표시
    관심 지역 저장/api/auth/login 이후 POST /api/realestate/watch-targets, 목록은 GET /api/realestate/watch-targets로그인 사용자의 관심 지역/단지 목록을 저장하고 topbar/화면에서 재사용
    운영 데이터 갱신Python pipeline이 /internal/realestate/public-data/raw-ingestions 적재 후 /promote-staging, 필요 시 /map/layer-snapshots/refresh공식 데이터가 market fact와 지도 layer에 반영되고 SSE로 업데이트 알림 가능
    +
    + +
    +

    9. 검증 근거

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    항목확인한 위치
    Endpoint 목록backend/src/main/java/**/*Controller.java@GetMapping, @PostMapping, @DeleteMapping
    요청/응답 필드backend/src/main/java/com/youbuyfirst/backend/**/dto/*.java record DTO와 controller inner record
    인증 정책backend/src/main/java/com/youbuyfirst/backend/auth/SecurityConfig.java
    서버/Swagger 설정backend/src/main/resources/application.yml, backend/pom.xml
    Frontend 호출 기준front/src/lib/*.ts, front/src/App.vue, front/src/__tests__/*.spec.ts
    + + +
    +
    + + diff --git a/docs/current/SSAFY_REAL_ESTATE_WBS.html b/docs/current/SSAFY_REAL_ESTATE_WBS.html new file mode 100644 index 0000000..8275edb --- /dev/null +++ b/docs/current/SSAFY_REAL_ESTATE_WBS.html @@ -0,0 +1,1272 @@ + + + + + + WBS + + + +
    +

    SSAFY 관통프로젝트 제출 문서

    +

    너나사 부동산
    WBS 작업분류체계

    +

    + 공식 실거래, 전세, 공급, 정책, 주요 일정 데이터를 지역과 단지 기준으로 묶어 보여주는 + 부동산 인사이트 탐색 서비스의 기획, 데이터, 백엔드, 프론트엔드, AI 근거 로그, 검증 작업을 + 제출용 WBS로 정리한 문서입니다. +

    + +
    +
    +
    +
    프로젝트명
    +
    너나사 부동산 YouBuyFirst RealEstate
    +
    +
    +
    프로젝트 유형
    +
    SSAFY 관통프로젝트 / 웹 서비스 / 데이터 기반 부동산 인사이트
    +
    +
    +
    일정 기준
    +
    5주차 WBS 기준안. 실제 제출 일정에 맞춰 날짜만 치환 가능
    +
    +
    +
    담당 표기
    +
    PM, BE, DATA, FE, AI, QA 역할 기준. 제출 전 팀원명으로 교체
    +
    +
    + +
    +

    문서 목적

    +

    + 평가자가 프로젝트 범위, 일정, 역할, 산출물, 선행 의존성, 검증 기준을 한눈에 확인할 수 있도록 + 작업을 기능 단위가 아니라 산출 가능한 work package 단위로 나눕니다. +

    +
    + Spring Boot + MySQL + Python Pipeline + Vue 3 + Kakao Map + Evidence Log + SSAFY HOME SQL +
    +
    +
    + + +
    + +
    +

    프로젝트 범위 요약

    +
    +
    + 핵심 사용자 경험 + 대시보드, 지도, 실거래 탐색, 지역/단지 상세, 주요 일정, 근거 로그 +
    +
    + 핵심 데이터 + SSAFY HOME SQL, 공공 실거래/전세, market fact, 정책/공급 일정 +
    +
    + 핵심 기술 + Spring Boot API, MySQL/Flyway, Python 수집 파이프라인, Vue 3 UI +
    +
    + 품질 기준 + provider, asOf, stale, 공개 지연, empty/error 상태를 화면과 API에 표시 +
    +
    + +

    제품 흐름

    +
    +
    공식 데이터 수집SSAFY HOME, 실거래, 전세, 일정
    +
    시장 사실 정규화provider, asOf, stale 분리
    +
    지역 target 연결region, complex, alias, graph
    +
    탐색 API 구성대시보드, 지도, 실거래, 상세
    +
    근거 로그 생성시장 사실, 일정, 이슈, caveat
    +
    프론트 시연PDF/발표/테스트 검증
    +
    + +

    주차별 마일스톤

    +
    + 기획/설계 + 구현/연동 + 검증/정리 +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    마일스톤W1W2W3W4W5완료 기준
    기획/요구사항 확정기능 범위, 화면 흐름, 금지 표현 확정
    DB/데이터 계약ERD, migration, SSAFY HOME bootstrap 검증
    백엔드 API 구현주요 화면 API와 통합 테스트 통과
    데이터 파이프라인수집, 정규화, 갱신 job, summary 출력
    프론트엔드 화면대시보드, 지도, 탐색, 상세, 일정 시연 가능
    AI 근거 로그근거, caveat, 금지 문구 guardrail 확인
    QA/배포/제출 문서테스트, Docker 실행, 발표 시나리오, WBS/PDF
    + +
    +
    +

    포함 범위

    +
      +
    • 지역/단지 target graph, alias, provider key 기반 데이터 모델
    • +
    • 실거래/전세/시장 사실/주요 일정 API와 화면 연결
    • +
    • 공식 데이터 기준시각, stale, 공개 지연 상태 표시
    • +
    • 지역/단지 상세의 근거 링크와 EvidenceLog 저장/조회
    • +
    +
    +
    +

    제외/주의 범위

    +
      +
    • 매수, 매도, 청약, 대출 행동 권유 문구 제외
    • +
    • 로그인 우회, CAPTCHA 우회, 프록시 회전 수집 제외
    • +
    • 확인되지 않은 값은 unknown, null, 확인 필요, mock으로 구분
    • +
    • 커뮤니티 반응은 핵심 결론이 아니라 보조 관찰 신호로 제한
    • +
    +
    +
    + + +
    + +
    +

    WBS 상세 1: 기획, 데이터, 백엔드

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    WBS작업 패키지주요 작업담당기간선행 작업산출물 / 완료 기준
    0.1프로젝트 착수주제 확정, 문제 정의, SSAFY 관통프로젝트 제출 요구사항 확인PMW1-프로젝트 개요, 팀 역할, 제출 산출물 목록
    0.2일정/협업 관리WBS 작성, Git 전략, daily task board, 회의 기록 기준 정리PM/QAW1-W50.1WBS, 주차별 일정표, 이슈/PR 관리 기준
    1.1서비스 기획핵심 사용자, 사용 흐름, 주요 화면, MVP와 후순위 기능 분리PM/FEW10.1제품 요약, 핵심 기능 목록, 사용자 시나리오
    1.2도메인 정책실거래, 전세, 공급, 정책, 시장 사실, 근거 로그 용어 기준 정리PM/BEW11.1금지 표현, 데이터 지연/불확실성 표시 기준
    1.3화면 IA대시보드, 지도, 실거래 탐색, 지역 상세, 주요 일정, 뉴스룸 흐름 정의FE/PMW1-W21.1화면 목록, route, 화면별 API 후보
    2.1원천 데이터 분석SSAFY HOME SQL dump의 dongcodes, houseinfos, housedeals 구조 분석DATA/BEW11.2원본 테이블 매핑표, provider key 기준
    2.2ERD/데이터 계약region, complex, target graph, alias, market fact, schedule, evidence log 모델 설계BE/DATAW1-W22.1ERD, Data Contract, API 응답 shape 초안
    2.3DB 마이그레이션Flyway migration, index, FK, seed data, H2 테스트 호환성 정리BEW22.2migration SQL, schema validation, 테스트 DB 통과
    2.4BootstrapSSAFY HOME 원본을 real_estate_regions, targets, complexes, aliases, market_facts로 변환DATA/BEW22.3bootstrap SQL, 검증 쿼리, 샘플 API 응답
    3.1백엔드 기반 설정Spring Boot 설정, profile, Swagger, validation, 공통 예외 응답 정리BEW1-W22.2실행 가능한 API 서버, 로컬/테스트 profile
    3.2Target API지역/단지 검색, 상세, 근처 단지, target graph drill-down API 구현BEW2-W32.4검색/상세 API, integration test, Swagger 확인
    3.3Market Fact API실거래/전세/시장 사실 조회, provider/asOf/stale, 공개 지연 상태 응답BEW2-W32.4대시보드/상세/지도에서 재사용 가능한 market fact API
    3.4실거래 탐색 API지역, 기간, 가격, 면적, 거래유형 필터와 정렬/페이징 구현BEW33.3거래 row 목록, 요약 통계, empty/stale 상태
    3.5주요 일정 API가격지수, 거래현황, 공급, 금리, 청약, 정책 발표 일정 저장/조회BE/DATAW3-W42.2월간 일정 API, sourceUrl, lastCheckedAt, stale 응답
    + + +
    + +
    +

    WBS 상세 2: 파이프라인, AI, 프론트엔드

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    WBS작업 패키지주요 작업담당기간선행 작업산출물 / 완료 기준
    4.1수집 adapter공공데이터, CSV, SSAFY HOME 원천을 raw/staging/정본 흐름으로 처리DATAW2-W32.2provider adapter, preflight, manifest, 실패 로그
    4.2정규화/백필실거래/전세 row를 market fact로 정규화하고 chunk 단위 재시도 지원DATA/BEW2-W44.1, 3.3백필 실행 결과, 중복 방지, 검증 쿼리
    4.3뉴스/리포트 근거공개 HTTP 기준 source registry, 제목/snippet/URL 중심 제한 저장DATAW3-W41.2source 후보표, parser smoke, 저장 정책 문서
    4.4일일 갱신 jobmarket fact, content, reaction snapshot, evidence log refresh를 한 번에 실행DATA/BEW44.2, 4.3one-shot command, summary JSON, scheduler 옵션
    5.1EvidenceLog 계약AI 평가 입력/출력, evidence, caveat, skipReason, version 필드 정의AI/BEW32.2, 3.3EvidenceLog schema, 저장/조회 API
    5.2유사 과거 비교reaction snapshot, market fact, timeline event 기반 유사 window 후보 생성AI/DATAW3-W44.2, 5.1유사 과거 후보 JSON, 이후 market fact 비교 shape
    5.3AI 요약/가드레일근거 요약, subtitle, caveat 생성과 매수/매도/청약 권유 금지 테스트AI/QAW4-W55.1금지 문구 테스트, 사용자용 근거 로그 샘플
    6.1프론트 기반Vue 3 라우팅, 레이아웃, 디자인 시스템, API client, mock fallback 정리FEW2-W31.3공통 shell, route, API 상태 처리 패턴
    6.2대시보드오늘의 지역 이슈, 시장 fact 요약, 주요 일정, 데이터 상태 카드 연결FEW3-W43.3, 3.5대시보드 화면, API fallback, stale badge
    6.3지도/지역 상세전국~동 단위 지도 흐름, 단지 marker, 상세 타임라인, 근거 링크 표시FEW3-W53.2, 3.3지도 화면, 지역/단지 상세, Kakao Map fallback
    6.4실거래 탐색필터, 거래 row, 지도 marker, 주변 시설, 공개 지연/empty/error 상태 구현FEW3-W53.4실거래 탐색 화면, URL query, 거래 상세 표시
    6.5주요 일정/뉴스룸공식 일정 캘린더, 뉴스/리포트 근거 feed, source label 표시FEW4-W53.5, 4.3일정 화면, 뉴스룸, source/asOf 표시
    6.6회원/보조 기능로그인/회원가입, 최근 조회, 관심 지역/단지 저장 등 시연 보조 기능 정리FE/BEW4-W56.1세션 API, 화면 진입 흐름, 비로그인 fallback
    + + +
    + +
    +

    검증, 제출 산출물, 위험 관리

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    WBS작업 패키지주요 작업담당기간상태완료 기준
    7.1백엔드 검증integration test, repository/service test, Swagger/API smoke, DB migration 확인BE/QAW4-W5필수Maven test 통과, 주요 API 샘플 응답 확보
    7.2프론트 검증Vitest, build, 화면 smoke, empty/loading/error/stale 상태 확인FE/QAW4-W5필수npm test/build 통과, 시연 화면 스크린샷
    7.3데이터 검증SSAFY HOME bootstrap count, market fact sample, batch summary, 중복/누락 점검DATA/QAW4-W5필수검증 쿼리 결과, source/asOf/stale 샘플
    7.4시연 환경Docker Compose, MySQL seed, backend/front 실행, 환경변수 예시 정리QA/BEW5중요로컬 실행 가이드, 데모 계정/데이터, 장애 대응 메모
    7.5발표/제출 문서README, WBS, ERD, API 명세, 발표자료, 회고, 화면 캡처 정리PM/QAW5제출PDF 제출본, 발표 시나리오, 최종 점검표
    7.6정책/법적 리스크 점검CAPTCHA/로그인 우회 금지, 원문 제한 저장, 행동 권유 문구 제거 확인PM/QAW5게이트금지 행위 없음, 원문 저장 범위 제한, 사용자 문구 검수
    + +
    +
    +

    Definition of Done

    +
      +
    • 대시보드에서 시장 fact와 주요 일정이 API 또는 명확한 mock fallback으로 보인다.
    • +
    • 실거래 탐색에서 지역, 기간, 가격, 면적 조건으로 거래 row를 확인할 수 있다.
    • +
    • 지역/단지 상세에서 provider, asOf, stale, 근거 링크, caveat가 함께 보인다.
    • +
    • 백엔드 테스트, 프론트 테스트/build, 데이터 bootstrap smoke를 수행한다.
    • +
    • 발표자는 데이터 지연, 공식 데이터 없음, 보조 반응의 한계를 설명할 수 있다.
    • +
    +
    +
    +

    주요 위험과 대응

    +
      +
    • 공공데이터 지연: 실시간처럼 표현하지 않고 기준시각과 stale 상태를 분리한다.
    • +
    • 데이터 중복: provider key, alias, target graph 기준으로 중복 merge 후보를 관리한다.
    • +
    • 시연 실패: mock fallback과 sample seed를 준비하고 API smoke URL을 별도 보관한다.
    • +
    • AI 과장 표현: 행동 권유, 상승 단정, 수익 보장 문구를 guardrail 테스트로 차단한다.
    • +
    • 수집 정책 리스크: 공개 HTTP 우선, 로그인/CAPTCHA/프록시 우회 금지를 문서와 코드 기준으로 유지한다.
    • +
    +
    +
    + +

    제출 전 수정해야 할 항목

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    항목현재 값수정 방법
    팀명/팀원명역할 기반 PM, BE, DATA, FE, AI, QA로 표기제출 전 실제 팀원 이름으로 교체
    프로젝트 기간W1-W5 주차 기준SSAFY 제출 기간이 확정되어 있으면 날짜 열 추가
    기능 상태WBS 계획/산출물 중심최종 완료 기능만 남기거나 상태 열을 추가
    + +

    + 출력 권장: Chrome 또는 Edge에서 인쇄, 용지 A4, 방향 가로, 여백 기본 또는 없음, 배경 그래픽 포함. +

    + + +
    + + diff --git a/docs/domains/realestate/DATA_CONTRACT.md b/docs/domains/realestate/DATA_CONTRACT.md index 66664e5..f022d79 100644 --- a/docs/domains/realestate/DATA_CONTRACT.md +++ b/docs/domains/realestate/DATA_CONTRACT.md @@ -71,6 +71,28 @@ MOLIT 실거래/전월세 API 백필은 `LAWD_CD + DEAL_YMD + providerDataset` `GET /internal/realestate/regions?regionLevel=&limit=&page=`는 pipeline이 지역 계층을 page 단위로 export하는 내부 API입니다. 단지 registry refresh는 이 응답의 `eupmyeondong` target을 읽어 실거래/전월세 fact가 5자리 시군구 `legalDongCode`만 갖고 있더라도 `시군구코드 + 법정동명` 조합으로 대표 `regionTargetId`를 동 단위까지 우선 연결합니다. +### `user_watch_targets` + +`user_watch_targets`는 로그인 사용자가 직접 저장한 관심 대상입니다. 공개 반응 순위나 시장 지표가 아니라 개인 저장 목록이며, `app_users.id`와 연결합니다. + +| 필드 | 타입 후보 | 기준 | +| --- | --- | --- | +| `id` | uuid/string | 관심 저장 row id | +| `userId` | uuid/string | `app_users.id` | +| `targetType` | enum | `region`, `complex`만 허용 | +| `targetId` | string | 지역 리포트 target id 또는 실거래 상세의 선택 item id | +| `displayName` | string | 오른쪽 `관심` 탭에 보여줄 지역명/단지명 | +| `landingPath` | string | `/realestate/`로 시작하는 복귀 경로 | +| `createdAt` / `updatedAt` | datetime | 저장/갱신 시각 | + +구현 계약: + +- `(userId, targetType, targetId)`는 unique입니다. 같은 대상을 다시 저장하면 `displayName`, `landingPath`, `updatedAt`을 갱신합니다. +- `GET /api/realestate/watch-targets`는 현재 로그인 사용자의 관심 목록을 최신 갱신순으로 반환합니다. +- `POST /api/realestate/watch-targets`는 `{ targetType, targetId, displayName, landingPath }`를 저장합니다. +- `DELETE /api/realestate/watch-targets?targetType=&targetId=`는 현재 로그인 사용자의 해당 관심만 해제합니다. +- 비로그인 요청은 401로 처리하며, 다른 사용자의 관심 row를 조회하거나 삭제할 수 없습니다. + ## 모델 계약 ### `real_estate_regions` @@ -281,9 +303,51 @@ SerpApi 같은 검색 API로 발견한 최근 이슈 후보도 공개 원문 링 | `confidence` | 0.0-1.0 | | `reviewState` | `candidate`, `approved`, `rejected`, `ambiguous` | +### `real_estate_regional_reports`, `real_estate_regional_report_sources` + +`real_estate_regional_reports`는 지역 분석 지도 오른쪽 패널의 최신 지역 리포트 정본입니다. 지도 기간 탭(`week`, `month`, `quarter`, `halfYear`, `year`)과 별도로 target별 최신 리포트 1개만 저장합니다. AI API 토큰이 없을 때도 seed/template 리포트를 DB에 넣을 수 있어야 하며, 이후 자동화는 같은 `targetId` row를 `POST /internal/realestate/regional-reports`로 덮어써 최신본을 교체합니다. + +리포트 본문은 기대 지점, 우려 지점, 종합 리포트 body, 근거 적재소 source ledger로 나눕니다. 매수·매도·청약 행동을 권유하지 않고, 실거래·전세·공급·정책·뉴스/리포트 근거를 함께 보는 관찰형 판단으로만 씁니다. 기대/우려는 1-2개를 기본으로 하며 의미 없는 항목을 억지로 채우지 않습니다. 각 기대/우려 포인트는 `전세·실거래 동행`, `입주 물량 부담`처럼 짧은 명사형으로 끝내고 서술형 문장이나 마침표를 쓰지 않습니다. 종합 리포트 body는 운영 설명이나 자동화 설명이 아니라 금융권 리포트처럼 냉정한 문체로 쓰며, `평가:`에는 해당 지역의 수요·공급·전세·정책·산업 변수를 해석하고 `전망:`에는 `관망 우위`, `선별 관망`, `선별 회복 가능성`, `방어력 우위`처럼 가격 단정이 아닌 방향성 판단을 드러냅니다. 시군구 리포트는 상위 시도 문장을 재사용하지 않고, 같은 시도 안에서도 생활권·산업·전세·공급 리스크가 다른 경우 기대/우려 포인트와 body를 분리합니다. 확인되지 않은 최근 이슈 후보는 source row의 `dataStatus=candidate` 또는 관련 content link의 `reviewState=candidate`로 남기고, 결론처럼 표현하지 않습니다. + +`real_estate_regional_reports` 필드: + +| 필드 | 기준 | +| --- | --- | +| `targetId` | `real_estate_targets.id`. 한 target에 최신 리포트 1개 | +| `reportId` | 외부 자동화/배치가 추적할 안정 id | +| `reportVersion` | 리포트 산식/스키마 버전 | +| `promptVersion` | AI 또는 template prompt 버전. 없으면 `null` | +| `modelName` | AI 모델명. seed/template이면 `null` 가능 | +| `generatedBy` | `seed:regional-report-v1`, `ai-api`, `operator` 등 | +| `title` / `headline` | API 요약 제목. 화면의 핵심 브리핑 카드는 headline을 반복하지 않고 기대/우려 포인트만 보여준다 | +| `summary` | 짧은 요약 | +| `body` | 금융권 리포트형 종합 본문. `평가:`와 `전망:`을 포함하되 가격 단정/행동 CTA 금지 | +| `expectationPointsJson` | 기대 지점 배열. 보통 1-2개. 짧은 명사형, 상승 단정이나 행동 CTA 금지 | +| `concernPointsJson` | 우려 지점 배열. 보통 1-2개. 짧은 명사형, 데이터 부족/지연/후보 상태 포함 | +| `dataQuality` | `ok`, `partial`, `candidate`, `stale`, `unknown` | +| `confidence` | 0.0-1.0. 없으면 `null` | +| `asOf` | 리포트 판단 기준 시각 | +| `publishedAt` | 이 최신 리포트를 화면에 공개한 시각 | + +`real_estate_regional_report_sources` 필드: + +| 필드 | 기준 | +| --- | --- | +| `id` | report source row id | +| `reportTargetId` | 연결된 report의 `targetId` | +| `displayOrder` | 근거 적재소 표시 순서 | +| `refType` | `external`, `content`, `market_fact`, `timeline_event`, `policy_event`, `evidence_log` | +| `refId` | 내부 ref id 또는 외부 source key | +| `label` | `실거래 근거`, `가격지표 근거`, `정책 근거` 같은 짧은 분류 | +| `title` | 사용자에게 보일 출처 제목 | +| `url` | 원문 링크. 내부 row만 있으면 `null` 가능 | +| `sourceName` | 출처명 | +| `publishedAt` | 원천 발행 시각. 알 수 없으면 `null` | +| `dataStatus` | `ok`, `candidate`, `partial`, `stale`, `unknown` | + ### `market_data_sources`, `market_data_schedules` -`market_data_sources`와 `market_data_schedules`는 `/indicators` 주요 일정 화면의 정본입니다. 지역/단지별 일정이 아니라 전국 단위 공식 발표, 공식 공표일, 청약 모집공고, 정책 보도자료 일정입니다. `marketDataScheduleRefreshJob`은 한국부동산원 R-ONE 보도자료, 국토부 보도자료, 청약Home APT 모집공고처럼 실제 공표 목록에서 확인한 행을 `published` row로 저장합니다. 통계누리 다음공표일과 한국은행 통화정책방향 결정회의처럼 공식 일정 페이지에서 확인한 행은 `scheduled`이되 `status=공식 일정`으로 저장합니다. 공식 원천에서 해당 월 날짜가 확인되지 않은 일반 점검/체크리스트 row는 만들지 않습니다. 같은 월을 다시 갱신할 때는 이번 배치에서 확인된 row로 월별 일정을 replace해서 원천에서 사라진 과거 row가 계속 노출되지 않게 합니다. 출처 URL 확인 결과는 `lastCheckedAt`, `stale`, `status`로 저장합니다. 저장 성공 후에는 SSE `topic=market-data-schedules`, `month=YYYY-MM` 업데이트를 발행해 열린 프론트 화면이 해당 월 일정 API를 재조회하게 합니다. +`market_data_sources`와 `market_data_schedules`는 `/indicators` 주요 일정 화면의 정본입니다. 지역/단지별 일정이 아니라 전국 단위 공식 발표, 공식 공표일, 청약 모집공고·접수·당첨, 정책 보도자료, 공공 공급 공고, 공동주택 공시가격 일정입니다. `marketDataScheduleRefreshJob`은 한국부동산원 R-ONE 보도자료, HUG 분양시장 공표자료, 국토부 보도자료, 청약Home APT 모집공고, LH/SH/GH/iH 공급 공고, 금융위원회 부동산 금융정책 보도자료, 부동산공시가격 알리미 공동주택가격 공지처럼 실제 공표 목록에서 확인한 행을 `published` row로 저장합니다. LH는 공고량이 많으므로 공공분양, 분양주택, 공공임대리츠 등 시장 관찰 신호가 큰 row를 우선하고 예비입주자 모집, 정정공고, 행복주택·국민임대·영구임대·매입임대 일반 row는 제외합니다. R-ONE 통계공표일정, 통계누리 다음공표일, 한국은행 통화정책방향 결정회의, 한국은행 월간통계 공표일정, 청약Home 접수 시작/마감·당첨자 발표처럼 공식 일정 페이지나 공식 공고에서 확인한 행은 `scheduled`이되 `status=공식 일정`으로 저장합니다. 한국은행 월간통계는 자금순환, 통화 및 유동성, 국민대차대조표, 생산자물가지수, 국내총생산, 금융기관 가중평균금리, 소비자동향조사, 기업경기조사·경제심리지수처럼 부동산 투자 환경 해석에 영향을 주는 금융·거시 통계만 캘린더 row로 둡니다. 국토부 실거래 공개시스템처럼 상시 공개·신고 지연 성격이 강한 데이터는 캘린더 row를 만들지 않고 market fact freshness/status에서 다룹니다. 공식 원천에서 해당 월 날짜가 확인되지 않은 일반 점검/체크리스트 row는 만들지 않습니다. 같은 월을 다시 갱신할 때는 이번 배치에서 확인된 row로 월별 일정을 replace해서 원천에서 사라진 과거 row가 계속 노출되지 않게 합니다. 출처 URL 확인 결과는 `lastCheckedAt`, `stale`, `status`로 저장하고, API 응답의 `sourceLinks[].dataStatus`는 `ok/error`로 파생합니다. 저장 성공 후에는 SSE `topic=market-data-schedules`, `month=YYYY-MM` 업데이트를 발행해 열린 프론트 화면이 해당 월 일정 API를 재조회하게 합니다. `market_data_sources` 필드: @@ -294,7 +358,7 @@ SerpApi 같은 검색 API로 발견한 최근 이슈 후보도 공개 원문 링 | `label` | 출처가 담당하는 데이터 종류 | | `provider` | `reb`, `molit`, `bok`, `applyhome`, `hug` 등 | | `sourceUrl` | 사용자가 이동할 공식 출처 URL | -| `category` | 가격지수, 실거래, 공급, 금융, 청약, 정책 등 | +| `category` | 가격지수, 거래현황, 공급, 금융, 청약, 정책, 공시가격 등 | | `tone` | 캘린더 일정 줄 색상 후보 | | `lastCheckedAt` | 배치가 해당 출처를 마지막으로 확인한 시각 | | `stale` | 출처 확인 실패 또는 지연 표시 여부 | @@ -308,7 +372,7 @@ SerpApi 같은 검색 API로 발견한 최근 이슈 후보도 공개 원문 링 | `sourceId` | `market_data_sources.id` | | `scheduleDate` | 캘린더에 표시할 날짜 | | `title` | 사용자에게 보일 일정명. 실제 공표명, 모집공고명, 공식 일정명을 우선 사용 | -| `category` | 가격지수, 실거래, 공급, 금융, 청약, 정책, 보증 등 | +| `category` | 가격지수, 거래현황, 공급, 금융, 청약, 정책, 보증, 공시가격 등 | | `sourceTitle` | 공식 출처명 | | `summary` | 짧은 설명 | | `sourceUrl` | 대표 홈페이지가 아니라 실제 공표 상세 URL, 청약 모집공고 상세, 통계 세부 화면, 통화정책 일정 화면처럼 해당 일정 맥락을 바로 확인할 수 있는 공식 URL | @@ -335,7 +399,7 @@ SerpApi 같은 검색 API로 발견한 최근 이슈 후보도 공개 원문 링 | 필드 | 기준 | | --- | --- | | `targetType` / `targetId` | 지역 또는 단지 | -| `factType` | `apt_trade`, `apt_rent`, `listing_count`, `price_index`, `policy_event`, `supply_event`, `transport_event` | +| `factType` | `apt_trade`, `apt_rent`, `offi_trade`, `offi_rent`, `rh_trade`, `rh_rent`, `sh_trade`, `sh_rent`, `silv_trade`, `listing_count`, `price_index`, `policy_event`, `supply_event`, `transport_event` | | `provider` | `molit`, `reb`, `kapt`, `naver`, `manual` 등 | | `providerDataset` | 원천 API/데이터셋 이름 | | `providerObjectId` | 외부 id | @@ -553,7 +617,7 @@ pipeline Daily Briefing 조립: ## API 후보 ```text -GET /api/realestate/targets/search?q= +GET /api/realestate/targets/search?q=&mode=&limit= GET /api/realestate/targets/{targetId} GET /api/realestate/targets/{targetId}/aliases GET /api/realestate/targets/{targetId}/graph?direction=&edgeType= @@ -563,6 +627,7 @@ GET /api/realestate/targets/{targetId}/market-facts GET /api/realestate/targets/{targetId}/nearby-complexes?limit= GET /api/realestate/targets/{targetId}/content?feed=&limit= GET /api/realestate/targets/{targetId}/evidence-logs?limit= +GET /api/realestate/targets/{targetId}/regional-report GET /api/realestate/targets/{targetId}/timeline?eventType=&limit= GET /api/realestate/market-facts?targetId=&legalDongCode=&factType=&limit=&page= GET /api/realestate/dashboard/market-summary?legalDongCode= @@ -590,6 +655,7 @@ POST /internal/realestate/policy-events POST /internal/realestate/content-items POST /internal/realestate/daily-briefings POST /internal/realestate/evidence-logs +POST /internal/realestate/regional-reports POST /internal/realestate/market-facts POST /internal/realestate/public-data/raw-ingestions POST /internal/realestate/public-data/promote-staging @@ -597,16 +663,20 @@ POST /internal/realestate/reaction-snapshots POST /internal/community/crawl-sources ``` +`GET /api/realestate/targets/search`는 지역/단지/생활권 공통 target 검색 API입니다. 대시보드 자동완성은 `mode=autocomplete&limit=8`을 사용하며, backend는 자동완성 모드에서 2글자 미만 검색어를 빈 결과로 처리하고 limit을 최대 8개로 제한합니다. 조회는 target name/slug/id/legalDongCode와 approved non-ambiguous alias를 함께 보되, 먼저 정확 일치와 앞부분 일치를 우선 정렬하고 부분 일치는 후순위 fallback으로 둡니다. 자동완성에서는 `대전 갈마동`처럼 상위 지역명과 동명이 함께 입력될 때 마지막 토큰으로 한 번 더 보조 조회하고, region 타입에 한해서 저장명 내부 포함 검색을 허용해 `eupmyeondong` row를 실거래 필터 후보로 반환할 수 있어야 합니다. 응답 row는 `targetId`, `targetType`, `displayName`, `slug`, `reviewState`, `dataStatus`, `regionLevel`, `parentTargetId`, `legalDongCode`, `regionCode`를 포함합니다. 단지 row의 자체 `legalDongCode`가 비어 있으면 부모 지역의 `legalDongCode`/`regionCode`를 내려 대시보드에서 실거래 화면의 지역 필터를 함께 복원할 수 있어야 합니다. + `GET /api/realestate/dashboard/market-summary`는 원천 `real_estate_market_facts`를 대시보드 카드용으로 얇게 요약합니다. 현재는 최신 `apt_trade`, 최신 `apt_rent`를 우선 노출하며, 변동률이 확인되지 않은 값은 `changePct=null`로 둡니다. `GET /api/realestate/indicators`는 주요 지표 화면의 API 우선 입력입니다. 현재는 별도 지표 통계 테이블이 생기기 전 단계이므로 `real_estate_market_facts`의 최신 `apt_trade`, `apt_rent`를 `가격 및 거래량` 그룹으로 요약합니다. 응답은 `period`, `dataStatus`, `asOf`, `groups[]`, `freshnessRows[]`를 포함하고, 각 group에는 `dataStatus`, `stale`, `provider`, `asOf`를 내려 UI가 실제 공공데이터와 `mock fallback`을 같은 위치에서 구분하게 합니다. 공급/수급, 수요/심리, 거시/금융 그룹은 후속 통계 원천이 연결될 때 같은 endpoint에 추가합니다. -`GET /api/realestate/market-data-schedules`는 `/indicators` 주요 일정 화면의 API 입력입니다. `month=YYYY-MM`을 받으며 응답은 `month`, `scheduleEvents[]`, `sourceLinks[]`를 포함합니다. `scheduleEvents[]`는 `id`, `date`, `title`, `category`, `source`, `summary`, `link`, `tone`, `provider`, `status`, `dataStatus`, `stale`, `lastCheckedAt`, `asOf`를 내려 캘린더 일정 줄과 공식 출처 이동을 구성합니다. `scheduleEvents[].link`는 source 대표 URL이 아니라 청약캘린더, 통계공표일정, 세부 통계 화면, 보도자료/공표보고서 목록/상세처럼 해당 일정 성격에 맞는 공식 세부 화면이어야 합니다. `sourceLinks[]`는 하단 공식 출처 링크를 구성하며, 일정이 비어 있어도 사용자가 공식 출처를 확인할 수 있게 유지합니다. +`GET /api/realestate/market-data-schedules`는 `/indicators` 주요 일정 화면의 API 입력입니다. `month=YYYY-MM`을 받으며 응답은 `month`, `scheduleEvents[]`, `sourceLinks[]`를 포함합니다. `scheduleEvents[]`는 `id`, `date`, `title`, `category`, `source`, `summary`, `link`, `tone`, `provider`, `status`, `dataStatus`, `stale`, `lastCheckedAt`, `asOf`를 내려 캘린더 일정 줄과 공식 출처 이동을 구성합니다. `scheduleEvents[].link`는 source 대표 URL이 아니라 R-ONE 통계 세부 화면, 청약 모집공고 상세, LH/SH/GH/iH 공급 공고 상세, 통계누리 세부 통계 화면, 한국은행 회의 일정·월간통계 공표일정, 금융위/국토부 보도자료 상세, 부동산공시가격 알리미 공지 상세처럼 해당 일정 성격에 맞는 공식 세부 화면이어야 합니다. `sourceLinks[]`는 하단 공식 출처 링크를 구성하며, `status`, `dataStatus`, `stale`, `lastCheckedAt`으로 source별 확인 성공/실패를 구분합니다. 일정이 비어 있어도 사용자가 공식 출처와 source 상태를 확인할 수 있게 유지합니다. `GET /api/realestate/batch-updates/stream`은 프론트가 배치 완료를 받는 SSE push 채널입니다. 서버는 event name `realestate-batch-update`로 `topic`, `month`, `acceptedItems`, `refreshedAt`만 보냅니다. `topic=newsroom`이면 `/api/realestate/newsroom`을, `topic=market-data-schedules`이면 `GET /api/realestate/market-data-schedules?month=YYYY-MM`을, `topic=map-layers`이면 `GET /api/realestate/map/layers`를 다시 조회합니다. 이벤트 payload는 화면 row 정본이 아니며, 데이터 정본은 DB-backed REST API 응답입니다. `GET /api/realestate/map/layers`는 자체 도식화 지도 heat layer 입력입니다. `layerType=sido`는 전국 시도, `layerType=sigungu&parentTargetId=region-seoul`은 특정 시도 하위 시군구를 반환합니다. 응답은 `targets[].targetId`를 정본 식별자로 씁니다. 가격지도 기간은 `week`, `month`, `quarter`, `halfYear`, `year`를 지원합니다. `week`는 `reb_rone_weekly_apt_sale_price_index_region`의 최신 주간 지수와 직전 주간 지수 2개를 비교합니다. 주간 지수 조회는 R-ONE 원천의 지역 코드 단위 차이를 보정하기 위해 현재 법정동코드, 구 단위 지역의 상위 시 코드, 강원 `51xxx`->`42xxx`, 전북 `52xxx`->`45xxx`, 시도 대표 법정동코드를 후보로 사용합니다. `month`/`quarter`/`halfYear`/`year`는 `reb_rone_monthly_apt_sale_price_index`의 최신 월간 지수와 1/3/6/12개월 전 지수를 비교합니다. 원천 fact는 `factType=price_index`, `unit=지수`로 저장하고, 계산된 변동률은 `map_layer_snapshots.changePct`에만 저장합니다. 공식 지수 provider에 없는 target은 임의값을 만들지 않고 snapshot을 생성하지 않으므로 UI는 해당 도형을 `공식 지수 미공표` 또는 `자료 없음` 상태로 표시해야 합니다. 실제 데이터 집계 전 seed 값은 `dataStatus=mock`, `stale=true`로 표시합니다. +`GET /api/realestate/targets/{targetId}/regional-report`는 지역 분석 오른쪽 패널의 최신 저장 리포트 입력입니다. 응답은 `reportId`, `targetId`, `targetName`, `regionLevel`, `regionCode`, `title`, `headline`, `summary`, `body`, `expectationPoints[]`, `concernPoints[]`, `dataQuality`, `confidence`, `asOf`, `publishedAt`, `sources[]`를 포함합니다. 지도 기간 탭은 리포트 본문을 바꾸지 않고, 리포트는 target별 최신본 1개만 노출합니다. `POST /internal/realestate/regional-reports`는 `reports[]` payload를 받아 `targetId` 기준으로 최신 row를 덮어쓰며, AI API 자동화나 운영자가 생성한 본문과 근거 ledger를 같은 contract로 적재합니다. + `POST /internal/realestate/map/layer-snapshots/refresh`는 배치가 호출하는 내부 API입니다. 요청은 `layerType`, `periods[]`, `asOf`를 받고, backend는 해당 layer의 `map_features`를 순회해 target별 공식 가격지수 fact를 우선 `map_layer_snapshots`로 요약합니다. 현재 가격지도 우선순위는 period별로 분리되어 `week`는 주간 지수 2개, `month`/`quarter`/`halfYear`/`year`는 월간 지수 2/4/7/13개가 필요합니다. 필요한 공식 index history가 부족하면 해당 target/period는 건너뛰며, `reaction snapshot`은 가격지도 색상 계산에 사용하지 않습니다. 거래 표본이 부족하거나 같은 관측일만 있으면 해당 target/period는 건너뛰며, 기존 seed/mock snapshot을 공식 데이터처럼 승격하지 않습니다. 실거래 표본 평균 비교에서 절대 변화율이 50%를 넘으면 값은 보존하되 `dataStatus=partial`, `stale=true`, 표본 기반 confidence로 낮춰 표시합니다. refresh가 성공하면 SSE `topic=map-layers`를 발행해 열려 있는 지도 화면이 최신 layer API를 다시 조회하게 합니다. 응답은 `layerType`, `periods[]`, `asOf`, `acceptedSnapshots`, `skippedTargets`입니다. 동/단지 상세의 내장 지도는 `GET /api/realestate/targets/{targetId}/nearby-complexes`를 우선 조회합니다. 응답은 `items[]`에 `targetId`, `name`, `address`, `region`, `latitude`, `longitude`, `tone`, `price`, `change`, `reaction`, `provider`, `asOf`, `dataStatus`, `stale`, `note`, `legalDongCode`, `coordinateProvider`, `coordinateStatus`를 포함합니다. API가 실패하거나 좌표가 없는 row만 내려오면 front fixture marker로 fallback하되, 좌표가 검증 전이면 값을 숨기지 않고 `dataStatus=mock|candidate|unknown`, `stale=true`로 표시합니다. diff --git a/docs/domains/realestate/PUBLIC_DATA_PROVIDERS.md b/docs/domains/realestate/PUBLIC_DATA_PROVIDERS.md index ed1768d..aa1a284 100644 --- a/docs/domains/realestate/PUBLIC_DATA_PROVIDERS.md +++ b/docs/domains/realestate/PUBLIC_DATA_PROVIDERS.md @@ -10,6 +10,13 @@ | --- | --- | --- | --- | --- | --- | | 10 | `molit_apt_trade` | 국토교통부 아파트 매매 실거래가 | 지역/단지 매매 실거래 market fact | 공공데이터포털 OpenAPI, XML | 예 | | 20 | `molit_apt_rent` | 국토교통부 아파트 전월세 실거래가 | 전세/월세 흐름, 전세가율 보조 | 공공데이터포털 OpenAPI, XML | 예 | +| 21 | `molit_offi_trade` | 국토교통부 오피스텔 매매 실거래가 | 오피스텔 매매 실거래 market fact | 공공데이터포털 OpenAPI, XML | 예 | +| 22 | `molit_offi_rent` | 국토교통부 오피스텔 전월세 실거래가 | 오피스텔 전월세 흐름 | 공공데이터포털 OpenAPI, XML | 예 | +| 23 | `molit_rh_trade` | 국토교통부 연립다세대 매매 실거래가 | 연립·다세대 매매 실거래 market fact | 공공데이터포털 OpenAPI, XML | 예 | +| 24 | `molit_rh_rent` | 국토교통부 연립다세대 전월세 실거래가 | 연립·다세대 전월세 흐름 | 공공데이터포털 OpenAPI, XML | 예 | +| 25 | `molit_sh_trade` | 국토교통부 단독다가구 매매 실거래가 | 단독·다가구 매매 실거래 market fact | 공공데이터포털 OpenAPI, XML | 예 | +| 26 | `molit_sh_rent` | 국토교통부 단독다가구 전월세 실거래가 | 단독·다가구 전월세 흐름 | 공공데이터포털 OpenAPI, XML | 예 | +| 27 | `molit_silv_trade` | 국토교통부 분양권 매매 실거래가 | 분양권 매매 실거래 market fact | 공공데이터포털 OpenAPI, XML | 예 | | 30 | `molit_official_apartment_price_csv` | 국토교통부 공동주택 호별 공시가격 | 공시가격, 단지/호 단위 기준 가격 | 공공데이터포털 대용량 CSV ZIP | 예 | | 40 | `reb_real_estate_statistics` | 한국부동산원 부동산통계 조회 서비스 | 가격지수, 실거래가격지수, 거래현황 | 공공데이터포털 OpenAPI, JSON/XML | 예 | | 50 | `molit_unsold_housing_stat` | 국토교통 통계누리 미분양주택현황보고 | 미분양/준공 후 미분양 공급 압력 | 통계누리 파일/API 후보 | 예 | diff --git a/docs/domains/realestate/SSAFY_ERD.html b/docs/domains/realestate/SSAFY_ERD.html new file mode 100644 index 0000000..ad5dcb3 --- /dev/null +++ b/docs/domains/realestate/SSAFY_ERD.html @@ -0,0 +1,1136 @@ + + + + + + 너나사 부동산 ERD - SSAFY 관통프로젝트 제출용 + + + +
    +
    +
    +
    SSAFY 관통프로젝트 제출용 ERD | MySQL/Flyway V68 | 2026-06-25
    +

    너나사 부동산 데이터베이스 ERD

    +

    테이블 목록보다 관계를 먼저 보여주는 제출용 ERD입니다. 중심 엔티티는 real_estate_targets이고, 모든 주요 기능은 target_id로 연결됩니다.

    +
    +

    핵심 ERD 전체 관계도

    +

    가운데 real_estate_targets가 모든 지역/단지 식별자의 중심이고, 주변 기능은 target_id로 연결됩니다.

    +
    + + + + + + + + + 사용자 기능 +부동산 대상 정본 +공공데이터 수집 +시장/지도 +리포트/근거 +콘텐츠/반응 보조 + 1:N +N:1 +1:0..1 +1:0..1 +1:N +N:M +1:N +1:0..1 +promote +1:N +1:N +1:0..1 +1:N +1:N +1:N +1:N +N:1 +1:N + + + app_users + 사용자 계정 + + + + + user_watch_targets + 관심 대상 + + + + + real_estate_targets + 부동산 대상 정본 + PK id +target_type +slug + + + + real_estate_regions + 지역 프로필 + + + + + real_estate_complexes + 단지 프로필 + + + + + real_estate_aliases + 별칭 사전 + + + + + real_estate_target +edges + 대상 관계 + + + + + real_estate_public +data_import_runs + 수집 실행 + + + + + real_estate_public +data_raw_items + 원천 raw + + + + + real_estate_market +fact_staging + 시장 사실 staging + + + + + real_estate_market +facts + 시장 사실 + + + + + map_layer_snapshots + 지도 레이어 값 + + + + + real_estate_regional +reports + 지역 분석 리포트 + + + + + real_estate_regional +report_sources + 리포트 근거 출처 + + + + + evidence_logs + AI 근거 로그 + + + + + evidence_log_items + 근거 항목 + + + + + content_items + 외부 콘텐츠 + + + + + content_target_links + 콘텐츠-대상 연결 + + + + + real_estate_reaction +snapshots + 반응 스냅샷 + + +
    +
    +
    관계도 포함 테이블41
    +
    주요 관계28
    +
    중심 엔티티target
    +
    검증 DB row2,438,210
    +
    +
    실선: 물리 FK점선: 논리 FK/애플리케이션 참조1:N, 1:0..1, N:M: 관계 수
    +
    +
    +

    부동산 대상 정본 상세 ERD

    +

    지역과 단지는 별도 독립 ID가 아니라 real_estate_targets.id를 확장합니다. 이 구조 덕분에 지도, 실거래, 리포트가 같은 target_id를 공유합니다.

    +
    + + + + + + + + + target graph + 1:0..1 +1:0..1 +parent 1:N +1:N +1:N +1:N +N:M + + + real_estate_targets + 부동산 대상 정본 + PK id +target_type +display_name +slug + + + + real_estate_regions + 지역 프로필 + PK/L-FK target_id +parent_region_id +legal_dong_code + + + + real_estate_complexes + 단지 프로필 + PK/L-FK target_id +region_target_id +coordinate_status + + + + real_estate_complex +provider_keys + 단지 외부키 + PK id +complex_target_id +provider_object_id + + + + real_estate_aliases + 별칭 사전 + PK id +target_id +normalized_alias + + + + real_estate_target +edges + 대상 관계 + PK id +from_target_id +to_target_id +edge_type + + + + real_estate_geocode + 지오코딩 캐시 + PK id +query_key +lat/lng + +
    +
    +
    +

    공공데이터 수집에서 시장 사실까지

    +

    공식 데이터는 raw를 바로 화면에 쓰지 않고 수집 실행, 원천 row, staging 검증을 거쳐 market_facts로 승격됩니다.

    +
    + + + + + + + + + 수집 pipeline +화면 조회용 시장/지도 +공식 일정 + 1:N +1:N +1:0..1 +promote +N:1 +1:N +1:N +1:N +1:N +계산 입력 +1:N + + + real_estate_public +data_datasets + 공공 데이터셋 + PK dataset_id +provider +source_url + + + + real_estate_public +data_import_runs + 수집 실행 + PK id +run_key +status + + + + real_estate_public +data_raw_items + 원천 raw + PK id +import_run_id +raw_payload_json + + + + real_estate_market +fact_staging + 시장 사실 staging + PK id +raw_item_id +validation_status + + + + real_estate_market +data_targets + 수집 대상 지역 + + + + + real_estate_targets + 부동산 대상 정본 + PK id +target_type + + + + real_estate_market +facts + 시장 사실 + PK id +target_id +fact_type +as_of/stale + + + + map_boundary_assets + 지도 경계 자산 + + + + + map_features + 지도 영역 + + + + + map_layer_snapshots + 지도 레이어 값 + + + + + market_data_sources + 공식 출처 + + + + + market_data_schedules + 데이터 일정 + + +
    +
    +
    +

    리포트, 타임라인, 근거 ERD

    +

    지역 리포트는 본문만 저장하지 않고 출처와 근거 항목을 분리합니다. 사용자가 보는 설명과 검증 근거를 함께 남기는 구조입니다.

    +
    + + + + + + + + + 지역 분석 +AI 근거 로그 +데일리 브리핑 + 1:0..1 +1:N +1:N +1:N +1:N +1:N +1:N + + + real_estate_targets + 부동산 대상 정본 + PK id +target_type + + + + real_estate_regional +reports + 지역 분석 리포트 + PK/FK target_id +report_id +body + + + + real_estate_regional +report_sources + 리포트 근거 출처 + PK id +FK report_target_id +url + + + + timeline_events + 상세 타임라인 + + + + + evidence_logs + AI 근거 로그 + PK id +target_id +summary + + + + evidence_log_items + 근거 항목 + PK id +FK evidence_log_id +ref_id + + + + daily_briefings + 데일리 브리핑 + PK id +briefing_date + + + + daily_briefing +sections + 브리핑 섹션 + PK id +FK briefing_id + + + + daily_briefing_source +items + 브리핑 출처 + PK id +FK briefing_id + +
    +
    +
    +

    사용자 기능과 커뮤니티 보조 ERD

    +

    사용자 저장/채팅은 app_users를 기준으로 하고, 공개 글과 콘텐츠는 부동산 판단의 정본이 아니라 target에 붙는 보조 관찰 근거입니다.

    +
    + + + + + + + + + 사용자 기능 +콘텐츠/반응 보조 + 1:N +1:N +1:N +N:1 +1:N +1:N +1:N +1:N +1:N +N:1 +1:N +1:N +1:N +N:1 + + + app_users + 사용자 계정 + PK id +username/email + + + + user_oauth_accounts + 소셜 로그인 + + + + + user_watch_targets + 관심 대상 + + + + + chat_messages + 채팅 메시지 + + + + + real_estate_targets + 부동산 대상 정본 + PK id +target_type + + + + crawl_targets + 크롤링 대상 + + + + + crawl_runs + 크롤링 실행 + + + + + community_posts + 커뮤니티 게시글 + + + + + community_post +diffusion_events + 확산 이벤트 + + + + + community_comment +collection_targets + 댓글 수집 후보 + + + + + content_items + 외부 콘텐츠 + + + + + content_target_links + 콘텐츠-대상 연결 + + + + + real_estate_reaction +snapshots + 반응 스냅샷 + + + + + real_estate_reaction +snapshot_issues + 반응 쟁점 + + + + + policy_events + 정책/공급 이벤트 + + + + + policy_event_targets + 정책 영향 대상 + + +
    +
    +
    +

    주요 관계 목록

    +

    다이어그램의 선을 표로 다시 정리한 것입니다. 물리 FK가 없는 곳도 서비스 contract상 같은 식별자로 연결되는 경우에는 논리 관계로 표시했습니다.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    부모/기준관계자식/참조연결 키의미
    real_estate_targets1:0..1real_estate_regionstarget_id지역 상세 profile
    real_estate_targets1:0..1real_estate_complexestarget_id단지 상세 profile
    real_estate_regions1:Nreal_estate_complexesregion_target_id단지가 대표 지역에 속함
    real_estate_targets1:Nreal_estate_aliasestarget_id검색/매칭 별칭
    real_estate_targetsN:Mreal_estate_target_edgesfrom_target_id/to_target_id포함, 생활권, 영향권 graph
    real_estate_complexes1:Nreal_estate_complex_provider_keyscomplex_target_idprovider별 단지 외부키
    real_estate_public_data_import_runs1:Nreal_estate_public_data_raw_itemsimport_run_id수집 실행별 raw landing
    real_estate_public_data_raw_items1:0..1real_estate_market_fact_stagingraw_item_id검증/정제 중간 단계
    real_estate_targets1:Nreal_estate_market_factstarget_id실거래, 전세, 가격지수 등 시장 사실
    market_data_sources1:Nmarket_data_schedulessource_id공식 출처별 공개 일정
    map_boundary_assets1:Nmap_featuresboundary_asset_id지도 경계 파일의 feature
    real_estate_targets1:Nmap_featurestarget_id지도 geometry와 내부 target 매핑
    real_estate_targets1:Nmap_layer_snapshotstarget_id기간별 지도 heat 값
    real_estate_targets1:0..1real_estate_regional_reportstarget_id지역별 최신 리포트
    real_estate_regional_reports1:Nreal_estate_regional_report_sourcesreport_target_id리포트 근거 출처
    real_estate_targets1:Ntimeline_eventstarget_id상세 화면 시간축 캐시
    real_estate_targets1:Nevidence_logstarget_idAI 평가 로그
    evidence_logs1:Nevidence_log_itemsevidence_log_idAI 평가가 인용한 근거 항목
    daily_briefings1:Ndaily_briefing_sectionsbriefing_id브리핑 본문 섹션
    daily_briefings1:Ndaily_briefing_source_itemsbriefing_id브리핑 출처
    app_users1:Nuser_oauth_accountsuser_id소셜 로그인 계정 연결
    app_users1:Nuser_watch_targetsuser_id사용자별 관심 대상
    real_estate_targets1:Nuser_watch_targetstarget_id관심 지역/단지 대상
    app_users1:Nchat_messagesuser_id로그인 채팅 작성자, 게스트는 nullable
    content_itemsN:Mcontent_target_linkscontent_item_id/target_id뉴스/리포트와 target 연결
    real_estate_targets1:Nreal_estate_reaction_snapshotstarget_id기간별 반응 집계
    real_estate_reaction_snapshots1:Nreal_estate_reaction_snapshot_issuessnapshot_id쟁점 비율
    policy_eventsN:Mpolicy_event_targetspolicy_event_id/target_id정책 영향 대상
    +
    +
    +

    엔티티 요약

    +

    부록은 컬럼 전체 나열이 아니라 ERD를 읽는 데 필요한 PK/FK와 역할 중심으로 줄였습니다.

    +

    사용자, 인증, 채팅

    + + + + + + + + + + + + + + + + + + + + + + +
    엔티티PK주요 연결 키현재 row
    app_users
    사용자 계정
    PK idusername, email, display_name11
    user_oauth_accounts
    소셜 로그인
    PK idFK user_id3
    user_watch_targets
    관심 대상
    PK idFK user_id, L-FK target_id3
    chat_messages
    채팅 메시지
    PK idFK user_id nullable15
    +

    부동산 대상 정본

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    엔티티PK주요 연결 키현재 row
    real_estate_targets
    부동산 대상 정본
    PK idtarget_type, slug61,938
    real_estate_regions
    지역 프로필
    PK/L-FK target_idparent_region_id19,546
    real_estate_complexes
    단지 프로필
    PK/L-FK target_idregion_target_id39,357
    real_estate_complex_provider_keys
    단지 외부키
    PK idcomplex_target_id39,186
    real_estate_aliases
    별칭 사전
    PK idtarget_id79,014
    real_estate_target_edges
    대상 관계
    PK idfrom_target_id, to_target_id121,458
    real_estate_geocode
    지오코딩 캐시
    PK idquery_key10,658
    +

    공공데이터 수집과 시장 사실

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    엔티티PK주요 연결 키현재 row
    real_estate_public_data_datasets
    공공 데이터셋
    PK dataset_idprovider16
    real_estate_public_data_import_runs
    수집 실행
    PK idrun_key5,955
    real_estate_public_data_raw_items
    원천 raw
    PK idimport_run_id486,218
    real_estate_market_fact_staging
    시장 사실 staging
    PK idraw_item_id457,064
    real_estate_market_data_targets
    수집 대상 지역
    PK idtarget_id2,475
    real_estate_market_facts
    시장 사실
    PK idtarget_id547,438
    market_data_sources
    공식 출처
    PK idprovider14
    market_data_schedules
    데이터 일정
    PK idFK source_id61
    map_boundary_assets
    지도 경계 자산
    PK idasset_type2
    map_features
    지도 영역
    PK idboundary_asset_id, target_id267
    map_layer_snapshots
    지도 레이어 값
    PK idtarget_id1,051
    +

    리포트, 타임라인, 근거

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    엔티티PK주요 연결 키현재 row
    real_estate_regional_reports
    지역 분석 리포트
    PK/FK target_idreport_id359
    real_estate_regional_report_sources
    리포트 근거 출처
    PK idFK report_target_id2,848
    timeline_events
    상세 타임라인
    PK idtarget_id546,845
    evidence_logs
    AI 근거 로그
    PK idtarget_id, snapshot_id138
    evidence_log_items
    근거 항목
    PK idFK evidence_log_id1,083
    daily_briefings
    데일리 브리핑
    PK idbriefing_date1
    daily_briefing_sections
    브리핑 섹션
    PK idFK briefing_id4
    daily_briefing_source_items
    브리핑 출처
    PK idFK briefing_id10
    +

    커뮤니티, 콘텐츠, 반응 보조

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    엔티티PK주요 연결 키현재 row
    crawl_targets
    크롤링 대상
    PK idtarget_id2
    crawl_runs
    크롤링 실행
    PK idsource, target_id122
    community_posts
    커뮤니티 게시글
    PK idsource, external_id8,024
    community_post_diffusion_events
    확산 이벤트
    PK idpost_id0
    community_comment_collection_targets
    댓글 수집 후보
    PK idpost_id490
    content_items
    외부 콘텐츠
    PK idurl_hash1,670
    content_target_links
    콘텐츠-대상 연결
    PK content_item_id+target_id+link_typetarget_id1,277
    real_estate_reaction_snapshots
    반응 스냅샷
    PK idtarget_id1,956
    real_estate_reaction_snapshot_issues
    반응 쟁점
    PK idsnapshot_id1,631
    policy_events
    정책/공급 이벤트
    PK idevent_type0
    policy_event_targets
    정책 영향 대상
    PK policy_event_id+target_id+impact_typetarget_id0
    +
    기준: Docker MySQL youbuyfirst, Flyway latest V68__guest_chat_messages.sql. Row 수는 현재 로컬 검증 DB의 InnoDB 추정치라 구조 설명 보조값입니다.
    +
    +
    + + \ No newline at end of file diff --git a/docs/layers/ui/screens/README.md b/docs/layers/ui/screens/README.md index 1b8356a..fd158fb 100644 --- a/docs/layers/ui/screens/README.md +++ b/docs/layers/ui/screens/README.md @@ -24,7 +24,6 @@ | `indicators` | `/indicators`, `/indicators/:category` | `indicators.md` | active | 공식 통계·실거래·청약·정책 주요 일정 | | `transaction-explorer` | `/realestate/transactions` | `transaction-explorer.md` | active | 실거래·전월세 필터링, 거래 row, 지역 집계 | | `region-detail` | `/realestate/targets/:targetId` | `region-detail/README.md` | active | 지역/단지 상세 리포트 | -| `mypage` | `/realestate/mypage` (`/realestate/watchlist` redirect) | `watchlist.md` | active | 사용자 저장 지역/단지, 알림 조건, 개인 메모, 저장 지역 비교 | ## Screen Brief 템플릿 diff --git a/docs/layers/ui/screens/auth.md b/docs/layers/ui/screens/auth.md index ab8ea9f..1287ec8 100644 --- a/docs/layers/ui/screens/auth.md +++ b/docs/layers/ui/screens/auth.md @@ -8,12 +8,13 @@ ## 화면 목적 -사용자가 세션 기반 계정으로 로그인하거나 회원가입해서 마이페이지의 저장 지역, 알림 조건, 관찰 메모를 사용자 계정 기준으로 관리할 수 있게 한다. 로그인, 회원가입, ID/PW 찾기는 서로 다른 화면으로 둔다. +사용자가 세션 기반 계정으로 로그인하거나 회원가입해서 상단 계정 상태와 채팅 참여 상태를 사용자 세션 기준으로 관리할 수 있게 한다. 로그인, 회원가입, ID/PW 찾기는 서로 다른 화면으로 둔다. ## 현재 섹션 -- 로그인 화면: 아이디와 비밀번호 입력 후 `/api/auth/login` 호출. 하단에 회원가입과 ID/PW 찾기 링크를 둔다. -- 회원가입 화면: 아이디, 이메일, 닉네임, 비밀번호 입력 후 `/api/auth/register` 호출. +- 로그인 화면: 아이디와 비밀번호 입력 후 `/api/auth/login` 호출. 성공하면 `/dashboard`로 이동한다. 하단에 회원가입과 ID/PW 찾기 링크를 둔다. +- 회원가입 화면: 아이디, 이메일, 닉네임, 비밀번호 입력 후 `/api/auth/register` 호출. 성공하면 `/dashboard`로 이동한다. +- 간편 로그인/회원가입: Google, Naver, Kakao 버튼은 `/api/auth/oauth/providers`에서 `configured=true`인 provider만 `/oauth2/authorization/{provider}` 링크로 이동한다. 로그인 화면의 provider 버튼은 각 사이트 아이콘을 사용한 3개 가로 배열로 둔다. 설정되지 않은 provider는 비활성 버튼으로 보여주고 Whitelabel 404 경로로 보내지 않는다. provider 인증 성공 후 백엔드가 `JSESSIONID`를 만들고 `/dashboard`로 돌려보낸다. - ID/PW 찾기 화면: 이메일과 아이디 입력 폼을 분리해서 둔다. 실제 복구 API 연결 전까지 제출은 비활성 상태다. - 상단 계정 상태: 로그인 전에는 `/auth/login` 링크, 로그인 후에는 `닉네임님 안녕하세요`와 로그아웃 버튼 표시. - 계정 플로우의 주요 폼은 화면 중앙에 배치하고, 활성 버튼은 hover/focus 상태가 보여야 한다. @@ -23,7 +24,7 @@ - loading: 버튼 문구를 `확인 중`, `생성 중`으로 바꾸고 중복 제출을 막는다. - empty: 로그인 전 상단에는 로그인 링크만 표시한다. - error: API 오류 문구를 각 폼 하단에 표시한다. -- stale/mock: 사용자 계정 데이터에는 mock 저장 목록을 표시하지 않는다. +- stale/mock: 계정 화면에는 사용자 저장 목록처럼 보이는 mock 데이터를 표시하지 않는다. ## API 후보 @@ -33,14 +34,20 @@ | `password` | backend auth | 회원가입 시 영문/숫자/특수문자 포함 8자 이상 | | `email` | backend auth | unique 이메일 | | `displayName` | backend auth | unique 닉네임, 상단 인사 표시 | +| `oauthProvider` | backend auth | Google/Naver/Kakao OAuth 시작 경로와 provider identity 연결 | +| `oauthProviders` | backend auth | `/api/auth/oauth/providers` 응답. provider별 `displayName`, `authorizationUrl`, `configured`로 버튼 활성 여부 결정 | ## 기획 확인 필요 - 비밀번호 찾기/이메일 인증은 아직 범위 밖. -- 소셜 로그인은 아직 범위 밖. +- OAuth provider client id/secret이 없는 로컬 환경에서는 간편 로그인/회원가입 버튼이 비활성 상태다. ## 변경 로그 - 2026-06-23: 세션 로그인 프론트 연결, 회원가입 양식 제한, 상단 로그인/로그아웃 상태 기준 추가. - 2026-06-23: 로그인 화면에서 회원가입 폼을 분리하고 `/auth/register`, `/auth/find-account` 라우트 추가. 로그아웃 hover/focus 상태 추가. - 2026-06-23: 로그인/회원가입/ID-PW 찾기 폼 중앙 정렬과 계정 플로우 버튼 hover/focus 기준 추가. +- 2026-06-25: 마이페이지 route 제거에 맞춰 로그인/회원가입 성공 후 이동 경로를 `/dashboard`로 변경. +- 2026-06-25: Google/Naver/Kakao OAuth 진입 버튼과 provider 인증 후 세션 로그인 흐름을 추가. +- 2026-06-25: OAuth provider 설정 상태 API를 기준으로 미설정 provider 버튼을 비활성화해 Whitelabel 404 이동을 막음. +- 2026-06-25: 로그인 화면의 Google/Naver/Kakao OAuth 버튼을 아이콘형 가로 배열로 변경. diff --git a/docs/layers/ui/screens/dashboard.md b/docs/layers/ui/screens/dashboard.md index 0ddbfd3..94a3276 100644 --- a/docs/layers/ui/screens/dashboard.md +++ b/docs/layers/ui/screens/dashboard.md @@ -16,20 +16,21 @@ - Today 3줄 브리핑: AI가 최근 핵심 쟁점, 공식 지표, 뉴스/리포트 후보를 큰 문장 3개로 요약함. 제목의 `Today`는 브랜드 포인트 컬러로 표시하고, 제목과 `자세한 분석 보러가기` CTA는 요약 카드 밖에 두고 같은 제목선 높이로 정렬함. 큰 외곽 박스 없이 01, 02, 03 숫자 마커와 핵심 문장만 강하게 보여주며, 소제목/보조설명 투트랙 문장은 쓰지 않음. 개별 카드별 바로가기는 두지 않음. 카드 높이는 옆 패널에 억지로 맞추지 않고 문장 밀도에 맞춰 과한 여백이 생기지 않게 유지함 - 지역 인사이트 한눈에: 가격 흐름, 전세 압력, 거래 강도, 정책/공급 이벤트가 함께 관찰되는 지역 묶음. 단지 후보는 보조 리스트로만 사용. 기대/우려/중립 아이콘 범례처럼 카드 내용을 반복 설명하는 보조 UI는 두지 않음 - 뉴스/리포트/영상/링크 feed: 실제 source icon 기반 외부 링크. 카드 헤더에는 영어 kicker나 `콘텐츠 반영` 같은 반복 상태 pill을 두지 않고, 점 아이콘 옆에 한글 제목을 단일 행으로 표시하며 `자세히 보기` 링크만 둠. 영상과 블로그·커뮤니티 목록은 순위형이 아니라 일반 콘텐츠 목록으로 보여주며 `1위`, `2위` 같은 번호를 붙이지 않음 -- 오른쪽 빠른 패널: `반응/지표/관심` 탭 전환 UI와 `라이징 스타` 보조 카드를 두지 않고, 현재 확인할 핵심 지역 흐름 카드 하나만 보여줍니다. 지표 상세는 `/indicators`, 실거래 탐색은 `/realestate/transactions`, 저장 지역 관리는 `/realestate/mypage`에서 처리합니다. +- 오른쪽 빠른 패널: `반응/지표/관심` 탭 전환 UI와 `라이징 스타` 보조 카드를 두지 않고, `관심`, `최근 본`, `채팅` 고정 탭만 둡니다. `최근 본`은 지역 분석에서 본 지역과 실거래에서 선택한 부동산을 최신순으로 최대 15개까지 브라우저에 보관하고, 항목 클릭 시 해당 지역 분석 또는 실거래 선택 위치로 이동합니다. 하단 `다크모드` 버튼은 shell, 본문, 우측 rail, 채팅창을 어두운 테마로 전환하고 선택값을 브라우저에 저장합니다. 지표 상세는 `/indicators`, 실거래 탐색은 `/realestate/transactions`, 지역 흐름 확인은 `/realestate/map`에서 처리합니다. - 공통 footer: `커뮤니티 반응`, `지역·단지 언급량`, `에이전트 근거 로그` 중심 설명을 쓰지 않고, 실거래·전세 흐름, 지도 기반 지역 변화, 주요 일정, 뉴스·리포트 이슈를 함께 보는 부동산 인사이트 서비스로 설명합니다. ## 빠른 패널 거래/일정 기준 - 거래 탐색: 최근 매매/전월세 거래 수, 최신 거래일, 공식 거래 없음 상태 -- 주요 일정: 가격지수, 실거래 공개, 미분양·공급, 금리, 청약, 정책 발표 +- 주요 일정: 가격지수·거래현황, 미분양·공급, 금리, 청약, 정책·공시가격 발표. 실거래 공개는 freshness/status로 분리 - 지도 흐름: 공식 가격지수 또는 검증된 market fact 기반 상승/하락 - 보조 반응: 공개 원문에서 반복되는 쟁점 후보가 있을 때만 축약 표시 +- 최근 본: 지역 분석 route와 실거래 선택 URL을 저장하며, 같은 항목은 중복 저장하지 않고 최신 위치로 올립니다. 로그인 기반 저장 목록이 아니라 현재 브라우저의 빠른 복귀 목록입니다. ## 상태와 빈 화면 - loading: 일일 브리핑, 지도/거래 요약, 일정 카드, feed skeleton을 먼저 보여줍니다. -- empty: 관찰 지역이 없으면 지역 분석 화면과 마이페이지 저장 흐름으로 이동할 수 있게 안내합니다. +- empty: 관찰 지역이 없으면 지역 분석 화면으로 이동할 수 있게 안내합니다. - error: 뉴스, 공공데이터, 공식 일정, 보조 원문 수집 실패를 영역별 badge로 분리합니다. - stale/mock: 실거래·공공통계는 공개 지연 가능성을 항상 표시합니다. @@ -68,6 +69,7 @@ - 2026-06-22: 오른쪽 라이징 스타 리스트를 제거하고, 우측 패널은 핵심 지역 흐름 카드 하나만 남김. - 2026-06-22: 좌측 `핵심 지역별 상승률` 차트를 `오늘의 부동산 브리핑`으로 교체. 대시보드 첫 블록은 특정 지역 비교가 아니라 최근 핵심 쟁점, 공식 지표, 뉴스/리포트 후보를 짧게 요약하는 역할로 변경. - 2026-06-22: `오늘의 부동산 브리핑`을 바로가기 카드 묶음이 아니라 AI 기반 핵심 요약 목록으로 변경. 개별 항목 링크는 제거하고 `자세한 분석 보러가기` CTA 하나만 유지. +- 2026-06-25: 마이페이지 route 제거에 맞춰 대시보드 안내의 저장 지역 전용 화면 연결을 제거. - 2026-06-22: 브리핑 제목을 `Today 3줄 브리핑`으로 바꾸고, `Today`만 브랜드 포인트 컬러로 표시. - 2026-06-22: 브리핑 제목과 CTA를 어두운 요약 박스 밖으로 분리하고, `AI 요약` 상태 pill은 제거함. - 2026-06-22: 브리핑의 큰 외곽 박스를 제거하고, 1·2·3번 항목 자체를 어두운 카드와 원형 번호 배지로 강조하도록 조정. @@ -79,4 +81,6 @@ - 2026-06-22: `Today 3줄 브리핑` 카드가 옆 패널 높이에 맞춰 과하게 늘어나지 않도록 stretch를 풀고, 숫자 열과 카드 padding을 줄여 문장 크기에 맞는 밀도로 조정. - 2026-06-22: 지역·단지 반응 카드 상단의 기대/우려/중립 아이콘 설명 범례를 제거. - 2026-06-22: 상단 내비게이션의 `지도` 메뉴명을 `지역 분석`으로 변경. +- 2026-06-25: 오른쪽 `최근 본` 탭을 지역 분석에서 본 지역과 실거래에서 선택한 부동산의 브라우저별 최신 15개 복귀 목록으로 정의. +- 2026-06-26: 오른쪽 rail 하단 `다크모드` 토글을 실제 테마 전환/저장 기능으로 정의. - 2026-06-22: 뉴스/리포트/영상/블로그 카드 헤더에서 `live feed`, `research feed` 같은 영어 kicker를 제거하고, 기존 점 아이콘 옆에 한글 제목만 단일 행으로 표시. diff --git a/docs/layers/ui/screens/indicators.md b/docs/layers/ui/screens/indicators.md index 0148804..82090a5 100644 --- a/docs/layers/ui/screens/indicators.md +++ b/docs/layers/ui/screens/indicators.md @@ -18,9 +18,9 @@ ## 현재 섹션 -- 일정 히어로: 지역 분석/마이페이지 화면의 공통 헤더 타이포와 간격을 따르는 화면 제목과 페이지 용도 설명 -- 월간 캘린더: 오른쪽 upcoming 패널 없이 화면 폭을 넓게 사용하며, 월 제목 양옆 화살표로 이전/다음 달을 이동합니다. 월을 바꾸면 `GET /api/realestate/market-data-schedules?month=YYYY-MM`를 다시 조회하고, 날짜 칸과 하단 일정 요약 목록을 같은 월 데이터로 함께 갱신합니다. 날짜 칸에는 `제목 · 한 문장 요약`을 흰 배경, 짙은 본문, 얇은 tone 표시선의 고대비 일정 줄로 표시하고 캘린더 아래에도 같은 일정의 상세 요약 행을 표시합니다. 헤더 오른쪽에는 가격지수, 실거래, 공급, 금융, 정책, 청약 색상 범례를 둡니다. 날짜 칸 일정 줄은 외부 링크가 아니라 같은 일정의 하단 요약 행으로 이동하는 요청 버튼이며, 이동된 요약 행에는 짧은 요청 확인 애니메이션을 적용합니다. 하단 요약 행은 실제 공표 상세 URL, 청약 모집공고 상세, 통계 세부 화면, 통화정책 일정 화면처럼 유효한 URL이 있을 때 관련 일정 사이트로 이동합니다. 날짜 칸 일정 줄의 배경은 상태 의미로 쓰지 않고, `공표 확인/공식 일정` 상태는 하단 상세 요약 행에서만 읽게 합니다. 좁은 화면에서는 7열을 억지로 압축하지 않고 캘린더 본문만 가로 스크롤해 일정 row 폭을 보존합니다. -- 공식 출처: 화면 하단 오른쪽 1/3 폭의 보조 링크 묶음으로 제공하며, 제목과 카드를 오른쪽 기준에 맞추고 설명문 없이 출처명과 분류만 3열 2줄로 작게 표시 +- 일정 히어로: 지역 분석 화면의 공통 헤더 타이포와 간격을 따르는 화면 제목과 페이지 용도 설명 +- 월간 캘린더: 오른쪽 upcoming 패널 없이 화면 폭을 넓게 사용하며, 월 제목 양옆 화살표로 이전/다음 달을 이동합니다. 월을 바꾸면 `GET /api/realestate/market-data-schedules?month=YYYY-MM`를 다시 조회하고, 날짜 칸과 하단 일정 요약 목록을 같은 월 데이터로 함께 갱신합니다. 날짜 칸에는 `제목 · 한 문장 요약`을 흰 배경, 짙은 본문, 얇은 tone 표시선의 고대비 일정 줄로 표시하고 캘린더 아래에도 같은 일정의 상세 요약 행을 표시합니다. 헤더 오른쪽에는 가격·공시, 거래현황, 공급, 금융, 정책, 청약 색상 범례를 둡니다. 날짜 칸 일정 줄은 외부 링크가 아니라 같은 일정의 하단 요약 행으로 이동하는 요청 버튼이며, 이동된 요약 행에는 짧은 요청 확인 애니메이션을 적용합니다. 하단 요약 행은 실제 공표 상세 URL, 청약 모집공고 상세, 통계 세부 화면, 통화정책 일정 화면처럼 유효한 URL이 있을 때 관련 일정 사이트로 이동합니다. 날짜 칸 일정 줄의 배경은 상태 의미로 쓰지 않고, `공표 확인/공식 일정` 상태는 하단 상세 요약 행에서만 읽게 합니다. 좁은 화면에서는 7열을 억지로 압축하지 않고 캘린더 본문만 가로 스크롤해 일정 row 폭을 보존합니다. +- 공식 출처: 화면 하단 오른쪽 1/3 폭의 보조 링크 묶음으로 제공하며, 제목과 카드를 오른쪽 기준에 맞추고 설명문 없이 출처명과 분류만 3열 compact grid로 작게 표시 ## 일정 항목 기준 @@ -33,12 +33,19 @@ ## 공식 출처 -- 한국부동산원 R-ONE: 가격지수, 주간·월간 통계 -- 국토교통부 실거래가 공개시스템: 매매·전월세 실거래 +- 한국부동산원 R-ONE: 가격지수·거래현황 공표일정, 주간·월간 통계 공표자료 +- 국토교통부 실거래가 공개시스템: 매매·전월세 실거래 freshness/status 확인. 고정 캘린더 row로 만들지 않습니다. - 국토교통 통계누리: 미분양, 인허가, 착공, 준공 등 공급 통계 -- 한국은행: 기준금리, 통화정책방향 결정회의 -- 청약Home: 청약 접수, 당첨자 발표, 경쟁률 +- 한국은행: 기준금리, 통화정책방향 결정회의, 자금순환·통화유동성·가중평균금리·소비심리·경제심리 등 월간통계 공표일정 +- 청약Home: 모집공고, 청약 접수 시작/마감, 당첨자 발표 - 국토교통부: 정책, 공급, 교통, 정비사업 보도자료 +- HUG 주택도시보증공사: 민간아파트 분양가격, 초기분양률, 분양시장 공표자료 +- LH청약플러스: 공공분양, 분양주택, 공공임대리츠처럼 시장 관찰 신호가 큰 공급 공고. 예비입주자 모집, 정정공고, 행복주택·국민임대·영구임대·매입임대 일반 row는 캘린더 과밀을 막기 위해 제외합니다. +- 서울주거포털 SH: 공공임대 입주자모집공고 +- 경기주택도시공사 GH: 분양·임대 공고 +- 인천도시공사 iH: 분양·임대 입주자모집공고 +- 금융위원회: 부동산 금융정책, 가계부채, 주택담보대출 관련 보도자료 +- 부동산공시가격 알리미: 공동주택가격 열람, 결정·공시, 이의신청 공지 ## 상태와 빈 화면 @@ -61,7 +68,7 @@ - `GET /api/realestate/market-data-schedules?month=YYYY-MM` - `GET /api/realestate/batch-updates/stream` -`marketDataScheduleRefreshJob`이 `market_data_sources`와 `market_data_schedules`를 갱신합니다. 배치는 뉴스 검색 크롤링이 아니라 한국부동산원 R-ONE 보도자료, 국토부 보도자료, 청약Home APT 모집공고, 통계누리 다음공표일, 한국은행 통화정책방향 결정회의처럼 공식 원천에서 실제 날짜가 확인된 row만 저장합니다. 출처 URL 확인 결과로 `확인 완료`, `확인 필요`, `stale`, `lastCheckedAt`를 갱신하되, 출처 확인만 성공했다고 일반 점검 일정을 만들지는 않습니다. 같은 월을 다시 갱신할 때는 이번 배치에서 확인된 row로 월별 일정을 replace해 사라진 공고나 잘못된 과거 row가 계속 남지 않게 합니다. +`marketDataScheduleRefreshJob`이 `market_data_sources`와 `market_data_schedules`를 갱신합니다. 배치는 뉴스 검색 크롤링이 아니라 한국부동산원 R-ONE 보도자료·통계공표일정, 국토부 보도자료, 청약Home APT 모집공고·접수·당첨 일정, HUG 분양시장 공표자료, LH/SH/GH/iH 공공 공급 공고, 금융위원회 부동산 금융정책 보도자료, 부동산공시가격 알리미 공동주택가격 공지, 통계누리 다음공표일, 한국은행 통화정책방향 결정회의·월간통계 공표일정처럼 공식 원천에서 실제 날짜가 확인된 row만 저장합니다. 한국은행 월간통계는 부동산 투자 환경과 연결되는 자금순환, 통화 및 유동성, 국민대차대조표, 생산자물가지수, 국내총생산, 금융기관 가중평균금리, 소비자동향조사, 기업경기조사·경제심리지수만 금융 일정으로 표시합니다. 실거래 공개시스템처럼 상시 공개 성격의 데이터는 고정 캘린더 row가 아니라 freshness/status 확인 대상으로 둡니다. 출처 URL 확인 결과로 `확인 완료`, `확인 필요`, `dataStatus`, `stale`, `lastCheckedAt`를 갱신하되, 출처 확인만 성공했다고 일반 점검 일정을 만들지는 않습니다. 같은 월을 다시 갱신할 때는 이번 배치에서 확인된 row로 월별 일정을 replace해 사라진 공고나 잘못된 과거 row가 계속 남지 않게 합니다. 배치가 저장을 끝내면 SSE push 채널이 `realestate-batch-update` 이벤트의 `topic=market-data-schedules`, `month=YYYY-MM`을 발행합니다. 프론트는 이벤트 payload를 일정 데이터로 직접 쓰지 않고, 현재 열려 있는 월과 일치할 때 `GET /api/realestate/market-data-schedules?month=YYYY-MM`를 다시 조회합니다. @@ -69,14 +76,14 @@ ## 기획 확인 필요 -- 한국은행 결정문/의사록, 통계누리 공표 완료 row, HUG/LH/SH/GH 공급 공고, 금융위/금감원/기재부 부동산 금융정책 공표를 추가 수집해야 합니다. +- 한국은행 결정문/의사록, 통계누리 공표 완료 row, 금감원/기재부 부동산 금융정책 공표는 추가 수집 후보입니다. - 정책·보도자료는 전체를 가져오면 노이즈가 커서, 부동산/공급/교통/대출 키워드 필터 기준을 계속 조정해야 합니다. ## 변경 로그 - 2026-06-22: 오른쪽 `이번 달 체크할 일정` 패널을 제거하고 월간 캘린더를 단일 컬럼 중심 화면으로 확대. - 2026-06-22: 공식 출처 링크 영역을 설명문 없는 낮은 보조 링크 줄로 축소. -- 2026-06-22: 일정 히어로의 제목 굵기, 크기, 설명 텍스트를 지역 분석/마이페이지 공통 헤더 스타일과 통일. +- 2026-06-22: 일정 히어로의 제목 굵기, 크기, 설명 텍스트를 지역 분석 공통 헤더 스타일과 통일. - 2026-06-22: 기존 주요 지표 허브와 카테고리 상세 화면을 폐기하고, `/indicators`를 공식 일정 캘린더와 출처 링크 화면으로 전환. - 2026-06-23: `/indicators`를 `GET /api/realestate/market-data-schedules?month=YYYY-MM` 기반으로 전환하고, `marketDataScheduleRefreshJob`이 월별 일정 row를 적재하는 기준을 추가. - 2026-06-23: 공식 출처 링크를 페이지 오른쪽 1/3 폭에 3열 2줄로 작게 모아 보이도록 조정. @@ -91,3 +98,7 @@ - 2026-06-24: 월 제목 양옆에 이전/다음 달 화살표를 추가하고, 월 이동 시 캘린더와 하단 일정 목록이 해당 월 API 데이터로 함께 바뀌도록 기준을 갱신. - 2026-06-24: 날짜 칸 일정 줄 클릭 시 같은 일정의 하단 요약 행으로 스크롤하고 요청 확인 애니메이션을 주며, 하단 요약 행 클릭은 관련 공식 일정 사이트로 이동하는 기준으로 정리. - 2026-06-24: 배치 일정 기준을 반복 점검/체크리스트 row에서 공식 원천 날짜가 확인된 row로 좁히고, 월 재갱신 시 사라진 row를 제거하도록 정리. +- 2026-06-24: 프론트 fallback 일정도 비워 API 실패나 수집 전 상태에서 임의 점검 일정이 실제 일정처럼 보이지 않게 정리. +- 2026-06-24: R-ONE 통계공표일정, HUG 분양시장 공표자료, 청약Home 접수 시작/마감·당첨자 발표를 공식 일정 후보로 추가하고, 실거래 공개시스템은 캘린더 row가 아니라 freshness/status 확인 대상으로 분리. +- 2026-06-24: LH/SH/GH/iH 공급 공고, 금융위원회 부동산 금융정책 보도자료, 부동산공시가격 알리미 공동주택가격 공지를 공식 날짜·상세 URL이 있는 일정 후보로 추가. +- 2026-06-24: 한국은행 월간통계 공표일정을 금융·거시 일정으로 추가해 금리 외 자금순환, 유동성, 대출금리, 소비·경제심리 공표일도 캘린더에 반영. diff --git a/docs/layers/ui/screens/realestate-dashboard.md b/docs/layers/ui/screens/realestate-dashboard.md index f286284..a318727 100644 --- a/docs/layers/ui/screens/realestate-dashboard.md +++ b/docs/layers/ui/screens/realestate-dashboard.md @@ -12,16 +12,19 @@ ## 현재 섹션 -- 상단 검색: 검색창 위에 `너나사 YouBuyFirst` 브랜드 타이틀을 크게 노출하고, 제목 아래 태그라인은 `당신을 위한 부동산 인사이트, 너나사`로 둬서 부동산 서비스 맥락을 간접적이지만 또렷하게 알림. 상단 영문 보조 라벨은 태그라인과 중복되므로 노출하지 않음. 지역, 단지, 법정동, 키워드 검색 진입점을 둠 +- 상단 검색: 검색창 위에 `너나사 YouBuyFirst` 브랜드 타이틀을 크게 노출하고, 제목 아래 태그라인은 `당신을 위한 부동산 인사이트, 너나사`로 둬서 부동산 서비스 맥락을 간접적이지만 또렷하게 알림. 상단 영문 보조 라벨은 태그라인과 중복되므로 노출하지 않음. 지역, 단지, 법정동 검색 입력을 실제 자동완성으로 연결하고, 검색어는 2글자 이상이 되는 즉시 `GET /api/realestate/targets/search?mode=autocomplete&limit=8`을 호출한다. 결과는 검색창 아래 리스트로 표시하며, 결과를 한 번 누르면 리스트를 닫고 바로 이동한다. 시도/시군구/생활권은 지역 분석 지도 리포트 패널이 선택된 상태로, 단지와 동 단위 `eupmyeondong`은 실거래 탐색 화면의 필터와 첫 매칭 상세가 열린 상태로 보낸다. 검색 결과 수나 외부 검색 노출량을 관심도 지표처럼 표시하지 않음 +- 검색 자동완성 보정: 한글 IME에서 조합 완료를 기다리지 않고, 두 번째 글자가 입력창 값에 보이는 순간 2글자 이상이면 즉시 호출한다. `regionLevel=eupmyeondong` 결과는 지역 분석이 아니라 `/realestate/transactions?region=<시군구 5자리>&q=<동명>`로 보내고, 실거래 화면은 route `q`와 일치하는 첫 결과를 상세 선택 상태로 열며 지도 중심을 해당 단지 좌표로 둔다. - Daily briefing 3줄 브리핑: 특정 지역 차트나 바로가기 카드 묶음이 아니라 저장된 일일 브리핑의 `summaryHeadlines[3]`만 짧은 명사형 헤드라인으로 표시. 대시보드도 `/realestate/daily-briefing` 상세의 3개 헤드라인 카드 디자인을 공유하되, 대시보드에서는 3개 요약을 세로로 쌓고 오른쪽 `지역·단지 반응 한눈에` 박스의 상하 높이에 맞춰 늘림. 3줄 브리핑 박스의 좌우 폭은 하단 `실시간 뉴스` 카드와 끝선이 맞도록 같은 그리드 간격을 사용함. CTA는 브랜드 주황 배경보다 어두운 고대비 리서치 카드 톤으로 두고, `자세한 분석 보러가기` 오른쪽 중앙에 밝은 원형 화살표 아이콘을 둠. 3개 요약 카드의 모서리는 대시보드에서만 조금 더 둥글게, 역할 라벨과 헤드라인은 여백에 맞춰 크게 표시함. 화면에는 `24시간`, `7일`, `1개월`, `3개월` 같은 구간 제목을 노출하지 않음 - 일일 브리핑 상세: `/realestate/daily-briefing`은 줄글 본문 화면이 아니라 건조한 금융권 리서치 브리프에 가까운 브리핑 보드로 구성함. 상단 기준 정보, 3개 헤드라인 카드, 핵심 흐름 리드/해설 문단, 단기 지표 기반 주목 지역 ledger, 간결한 시장 변수 list, 관련 뉴스·리포트 ledger를 분리해 보여줌. 실제 `sourceItems`가 있으면 출처 라벨과 원문 근거 링크로 표시하고, 없을 때는 본문을 반복하지 않고 뉴스룸, 정책·통계 리포트, 주요 일정, 전국 지역 지도 같은 내부 확인 화면으로 연결함 - 확인할 지역/생활권 후보. 기대/우려/중립 아이콘 범례처럼 카드 내용을 반복 설명하는 보조 UI는 두지 않고, `지역·단지 반응 한눈에` 헤더에는 기간 탭이나 상세 바로가기 액션을 노출하지 않음. 반응 카드 헤더와 첫 카드 시작선은 왼쪽 `Daily briefing` 3줄 브리핑 헤더/카드 시작선과 같은 높이를 유지함 +- `지역·단지 반응 한눈에` 카드 본문은 `GET /api/realestate/map/layers?layerType=sigungu`의 주간 상승률 양수 TOP5만 슬라이드로 표시하고, 각 카드 오른쪽 위에는 `주간 +x.xx%`를 한 줄 배지로 둠. 카드의 `기대`/`우려` 영역은 단어 구름을 쓰지 않고 같은 target의 최신 지역 리포트 `expectationPoints`, `concernPoints` 문장을 최대 3개씩 그대로 표시함. 문장은 줄바꿈되며, 각 문장 행은 작은 정보 카드처럼 세로 공간을 고르게 채워 빈 여백이 크게 남지 않아야 함. 카드 전체를 클릭하면 `/realestate/map/:regionId?selectedTargetId=:targetId&selectedRegionCode=:geometryCode&period=week`로 이동해 지역 분석 지도 안의 해당 시군구 리포트 패널을 바로 열어야 함. 카드 우상단 언급 배지, 지역/단지·생활권 `열기` 메타, 기대/우려 관련 링크 묶음, 반응 비율 바는 노출하지 않음 - 실거래·전세·공급 변화 요약 - 실시간 이슈, 뉴스, 컬럼 링크 - 실거래/전세/매물/정책 데이터 상태 badge - 공개 지연, provider/asOf, 데이터 부족 rail - 공통 footer: 커뮤니티 반응 중심 설명이 아니라 실거래·전세 흐름, 지도 기반 지역 변화, 주요 일정, 뉴스·리포트 이슈를 정리하는 부동산 인사이트 서비스로 설명 -- 오른쪽 빠른 패널: `반응/지표/관심` 탭 전환과 `라이징 스타` 보조 카드를 두지 않고, 첫 화면에서는 `라이브 패널 · 주간 가격 흐름`을 본문과 같은 카드 안의 상단 헤더로 합쳐 유지. 헤더는 작은 라벨이 아니라 카드 상단을 꽉 채우는 진한 오렌지색 면으로 두어 즉시 눈에 들어오게 함. 본문 카드는 가운데 `지역·단지 반응 한눈에` 슬라이드 카드의 윗단과 같은 높이에서 시작하도록 배치함. 헤더와 상승 영역 사이에는 별도 gap을 두지 않고 바로 맞닿게 함. 본문에는 `언급`, `열기`, `시장`, `상승률`, `표본`, `데이터 근거` 같은 보조 지표 묶음을 쓰지 않고, 위 절반에는 `이번주 가장 많이 상승한 지역`, 아래 절반에는 `이번주 가장 많이 하락한 지역`을 표시함. 두 영역은 절반씩 나누고 기존 연한 실선으로 구분하며, 각 영역에서는 `이번주...지역` 라벨을 가장 위에 두고 지역명을 그 아래 왼쪽에 둔 뒤, 상승/하락 수치는 오른쪽 아래에 둬 대각선으로 읽히게 함. `최근 1주 · 기준일` 메타도 카드 하단 오른쪽 끝에 둠. 두 영역은 폐기된 target 상세가 아니라 `/realestate/map/:regionId?selectedTargetId=:targetId&selectedRegionCode=:geometryCode&period=week`로 이동해 지역 분석 지도의 해당 시군구 리포트 패널을 바로 열어야 함. 지표, 실거래, 저장 지역 관리는 각 전용 화면으로 이동해 확인함 +- 오른쪽 빠른 패널: `반응/지표/관심` 탭 전환과 `라이징 스타` 보조 카드를 두지 않고, 첫 화면에서는 `라이브 패널 · 주간 가격 흐름`을 본문과 같은 카드 안의 상단 헤더로 합쳐 유지. 헤더는 작은 라벨이 아니라 카드 상단을 꽉 채우는 진한 오렌지색 면으로 두어 즉시 눈에 들어오게 함. 본문 카드는 가운데 `지역·단지 반응 한눈에` 슬라이드 카드의 윗단과 같은 높이에서 시작하도록 배치함. 헤더와 상승 영역 사이에는 별도 gap을 두지 않고 바로 맞닿게 함. 본문에는 `언급`, `열기`, `시장`, `상승률`, `표본`, `데이터 근거` 같은 보조 지표 묶음을 쓰지 않고, 위 절반에는 `이번주 가장 많이 상승한 지역`, 아래 절반에는 `이번주 가장 많이 하락한 지역`을 표시함. 두 영역은 절반씩 나누고 기존 연한 실선으로 구분하며, 각 영역에서는 `이번주...지역` 라벨을 가장 위에 두고 지역명을 그 아래 왼쪽에 둔 뒤, 상승/하락 수치는 오른쪽 아래에 둬 대각선으로 읽히게 함. `최근 1주 · 기준일` 메타도 카드 하단 오른쪽 끝에 둠. 두 영역은 폐기된 target 상세가 아니라 `/realestate/map/:regionId?selectedTargetId=:targetId&selectedRegionCode=:geometryCode&period=week`로 이동해 지역 분석 지도의 해당 시군구 리포트 패널을 바로 열어야 함. 지표와 실거래는 각 전용 화면으로 이동해 확인하고, 저장 지역 전용 화면은 두지 않음 +- 공통 오른쪽 고정 rail: `관심`, `최근 본`, `채팅` 순서로만 둔다. 기존 `반응`, `실시간` 탭은 제거하고, 채팅 탭은 로그인 사용자와 비로그인 게스트가 닉네임으로 참여하는 좁은 피드 UI를 제공한다. 사이트 첫 진입 기본값은 채팅 패널이 열린 상태이며, 이때 최근 메시지 조회, SSE 연결, `/api/chat/presence` heartbeat를 바로 시작한다. 사용자가 패널을 닫거나 `관심`/`최근 본` rail로 전환하면 채팅 heartbeat를 멈춘다. 채팅방 내부에는 `전체/채팅/뉴스` 같은 보조 필터 탭을 두지 않고, 메시지 닉네임 옆에는 채팅/뉴스 같은 텍스트 badge를 반복 표시하지 않는다. 채팅 상단의 참여자 수는 버튼이 아니라 초록 점과 숫자가 같은 줄에 놓이는 상태 표시값으로 왼쪽에 두고, 값은 채팅 패널을 연 로그인/게스트 세션이 `/api/chat/presence`로 보낸 최근 45초 heartbeat 기준 `activeSessionCount`다. `A-`/`A+` 메시지 글자 크기와 닉네임은 오른쪽 끝 액션 묶음으로 정렬한다. 로그인 사용자는 `displayName` 텍스트를 그대로 보여주고 이 텍스트를 클릭하면 `닉네임 변경` 제목이 있는 작은 팝업에서 채팅 닉네임을 로컬로 수정할 수 있다. 로그인/게스트 모두 닉네임 문자열 해시 기반의 고정 색상 팔레트를 사용하며, 같은 닉네임은 같은 색상으로 표시한다. 가입자 작성 메시지는 그 위에 천천히 흐르는 얇은 하이라이트 띠 오버레이와 작은 인증 마크를 추가 표시한다. 비로그인 사용자는 채팅 목록을 블러/모자이크 오버레이로 본 뒤 `로그인 하기` 또는 `비로그인으로 참여하기`를 선택하고, 게스트 닉네임 입력 후 인증마크와 그라데이션 없이 채팅할 수 있다. `닉네임 설정` 문구 버튼, 톱니, 팝업, 닫기 버튼은 두지 않는다. 채팅 패널 배경은 본문 사이트 배경과 구분되는 블루그레이 톤으로 분리하고, 본문과의 경계에는 두꺼운 왼쪽 선과 그림자를 둔다. 내가 쓴 메시지는 오른쪽 정렬의 옅은 배경 말풍선으로 구분한다. 채팅 메시지는 `POST /api/chat/messages`로 서버 DB에 저장하고, 패널이 열릴 때 `GET /api/chat/messages?limit=50`으로 최근 메시지를 읽은 뒤 `/api/chat/messages/stream` SSE(EventSource)로 새 메시지를 즉시 반영한다. 5초 재조회는 stream 끊김을 보완하는 안전망으로 유지한다. 지역 분석/실거래 첨부는 메시지의 `attachment`로 저장하며, 첨부 카드는 `종류+제목 / 필요할 때만 보이는 설명 / 기간+등락률`로 압축한다. 실거래는 대상 설명을 유지해 3줄, 지역 분석은 설명이 기간과 중복되면 숨겨 2줄로 보이고, 카드 클릭 시 해당 지역 분석 또는 실거래 상세 URL로 이동한다. WebSocket/STOMP 확장은 다음 contract 후보로 둔다. ## 인사이트 지표 방향 @@ -57,10 +60,11 @@ 현재 구현: - `GET /api/realestate/dashboard/market-summary` 응답은 대시보드의 시장 요약과 오른쪽 `인사이트` 탭에 우선 표시합니다. `/indicators`는 지표 상세가 아니라 공식 발표 일정 화면으로 둡니다. API가 비거나 실패하면 fixture 지표를 섞지 않고 `수집 전/insufficient` 또는 오류 상태를 표시합니다. +- `GET /api/realestate/targets/search?q=&mode=autocomplete&limit=8` 응답을 상단 검색 자동완성 후보에 사용합니다. 입력은 2글자 이상부터 즉시 요청하고, 한글 IME 조합 중에도 input 값이 2글자 이상이면 요청 대상입니다. 결과 row는 `targetId`, `targetType`, `displayName`, `regionLevel`, `parentTargetId`, `legalDongCode`, `regionCode`, `dataStatus`를 사용합니다. 시도/시군구 row는 `/realestate/map/:parentTargetId?selectedTargetId=&selectedRegionCode=&period=week`, `eupmyeondong` row와 단지 row는 `/realestate/transactions?region=&q=`로 이동합니다. `eupmyeondong` row는 리스트에서 `실거래 지역`으로 표시해 지역 분석 진입과 구분합니다. 단지 이동 후 실거래 화면은 route `q`와 일치하는 첫 결과를 상세 선택 상태로 열고 지도 중심을 해당 좌표로 둡니다. - 2026-06-16 MVP 기준 `dashboard/market-summary`는 한국부동산원 R-ONE 전국 주요 지표(`매매가격지수 변동률`, `전세가격지수 변동률`, `아파트 매매거래호수`)를 우선 표시할 수 있습니다. 이 값은 전국 배경 지표이며, 지역별 가격 fact처럼 가중하거나 단정하지 않습니다. - `GET /api/realestate/transactions/summary?windowMinutes=10080&limit=10` 응답을 관심 drawer, 거래 drawer, 지역 시장 흐름 카드에 우선 표시합니다. 검색창 주변에는 반응 필터, 언급 합계/지연 지표/관찰 수 메타 칩, "7일 · 반응 데이터 반영" 같은 기간·상태 eyebrow, "특정 지역에 반응이 가장 많이 몰렸다" 같은 단정형 자동 헤드라인, 임시 gauge를 만들지 않습니다. API가 비거나 실패하면 fixture를 섞지 않고 `수집 전/insufficient` 또는 오류 상태를 표시합니다. - 대시보드 좌측 첫 블록의 `Daily briefing` 3줄 브리핑은 `GET /api/realestate/daily-briefings/latest` 응답의 `summaryHeadlines[3]`만 표시합니다. API가 비면 mock 문장을 조합하지 않고 `브리핑 생성 대기` 상태로 둡니다. 대시보드 요약 3개는 상세 브리핑 페이지의 `daily-briefing-headline-card` 디자인을 공유하되 세로 3줄로 배치하고, 카드 묶음 전체 높이는 오른쪽 반응 패널과 맞춥니다. `자세한 분석 보러가기`는 오른쪽 화살표 CTA와 함께 `/realestate/daily-briefing`으로 이동합니다. -- `GET /api/realestate/map/layers?layerType=sigungu` 응답의 `week` 기간은 오른쪽 빠른 패널의 주간 상승 1위/하락 1위 세부지역에만 사용합니다. 세부지역별 비교/지도 탐색은 `/realestate/map`에서 다룹니다. +- `GET /api/realestate/map/layers?layerType=sigungu` 응답의 `week` 기간은 `지역·단지 반응 한눈에`의 주간 상승률 TOP5와 오른쪽 빠른 패널의 주간 상승 1위/하락 1위 세부지역에 사용합니다. TOP5 카드의 기대/우려 문장은 각 target의 `GET /api/realestate/targets/{targetId}/regional-report` 최신 리포트에서 `expectationPoints`, `concernPoints`를 그대로 표시합니다. 세부지역별 비교/지도 탐색은 `/realestate/map`에서 다룹니다. - `GET /api/realestate/newsroom?feed=all&page=1&pageSize=40` 응답을 대시보드 뉴스/리포트/영상/커뮤니티 링크 카드에 우선 표시합니다. API가 비거나 실패하면 mock feed 대신 수집 전/오류 상태를 표시합니다. - SerpApi/content 후보는 화면에서 임의 복제하지 않습니다. 리포트는 KB금융/KB Think, 하나금융연구소, 우리금융경영연구소, 주택금융연구원/HF, 한국금융연구원/KIF, 건설산업연구원, 국토연구원/K-REMAP, 한국은행, HUG, 한국부동산원 같은 금융·연구·공공기관 도메인만 `정책·통계 리포트`로 분류하고, 언론사가 보고서 발간 소식을 다룬 기사는 `실시간 뉴스`로 둡니다. 배치는 기관군별 source로 나뉘어 한 기관 결과가 카드 전체를 독점하지 않게 합니다. - 영상 후보는 집코노미, 매부리TV, 부읽남TV, 삼프로TV, 스마트튜브, 김작가TV처럼 부동산 분석·토론·인터뷰 맥락이 있는 YouTube 채널 RSS에서만 가져옵니다. Shorts URL, 600초 미만 영상, 조회수 10,000회 미만 영상, 검색어에 "영상"이 들어간 일반 기사, 채용공고, 공유 래퍼 페이지, 액션형 추천 제목은 영상 카드에 넣지 않습니다. 공개 YouTube 상세 메타의 `keywords`와 Atom RSS의 `media:description`/`media:statistics`가 부동산 주제·조회수 근거를 줄 때 제목 주제어 부족을 보완하는 근거로 사용합니다. @@ -72,6 +76,23 @@ ## 변경 로그 +- 2026-06-25: 사이트 첫 진입 시 공통 오른쪽 rail의 채팅 패널을 기본으로 열고, 첫 화면에서 메시지 조회/SSE/presence heartbeat를 바로 시작하게 변경. 패널을 닫거나 관심/최근 본으로 전환하면 채팅 heartbeat는 멈춘다. +- 2026-06-25: 로그인/게스트 닉네임은 닉네임 문자열 해시 기반의 고정 색상 팔레트를 사용해 같은 닉네임이면 같은 색상으로 표시한다. 가입자 닉네임은 채팅 헤더와 메시지 작성자명에서 해당 고정 색상을 기본으로 유지하고, 천천히 흐르는 얇은 하이라이트 띠 오버레이만 얹어 읽기성과 움직임을 함께 살린다. 로그인한 내 새 메시지는 응답에서 인증 필드가 잠깐 빠져도 가입자 스타일을 유지한다. 게스트 닉네임은 인증마크 없이 고정 색상 텍스트로 유지한다. +- 2026-06-25: 비로그인 사용자도 채팅 패널에서 `로그인 하기` 또는 `비로그인으로 참여하기`를 선택할 수 있게 변경. 게스트는 닉네임 입력 후 채팅 가능하며 메시지에는 인증마크를 붙이지 않는다. 참여자 수는 채팅 패널 heartbeat를 보내는 로그인 세션과 게스트 세션을 합산한다. +- 2026-06-25: 공통 오른쪽 고정 rail에서 `반응`, `실시간` 탭을 제거하고 `채팅` 탭을 추가. 탭 순서는 `관심`, `최근 본`, `채팅`으로 두며 채팅은 알파벳 대신 가로형 말풍선 아이콘으로 표시. 채팅 패널은 로그인한 닉네임 기반 피드와 입력창을 제공하고, 메시지는 서버 저장/조회 API로 새로고침 후에도 유지한다. 지역 분석/실거래 첨부는 말풍선 버튼으로 입력창에 붙이고 전송 메시지 카드에 저장한다. +- 2026-06-25: 채팅 헤더에서 참여자 수만 왼쪽 상태값으로 두고 글자 크기/닉네임 컨트롤은 오른쪽 끝으로 정렬. 참여자 수는 `/api/chat/presence`의 `activeSessionCount` 기반으로 표시하되, 단순 방문 세션 총합이 아니라 채팅 패널을 연 세션의 최근 45초 heartbeat만 센다. `닉네임 설정` 버튼 문구를 제거하고 로그인 사용자의 닉네임 텍스트 클릭 시 `닉네임 변경` 팝업에서 로컬 수정하게 변경. 채팅 패널 배경을 본문과 구분되는 블루그레이 톤으로 분리하고 왼쪽 경계선/그림자를 강화. +- 2026-06-25: 채팅 메시지를 `chat_messages` 테이블과 `/api/chat/messages` 서버 API에 연결. 로그인 사용자의 작성은 세션 인증이 필요하고, 최근 메시지는 게스트/로그인 모두 읽을 수 있어 로컬호스트와 터널 주소, 새로고침 사이에 같은 피드가 유지된다. +- 2026-06-25: 열린 채팅 패널에 `/api/chat/messages/stream` SSE를 연결해 다른 사용자가 저장한 메시지를 새로고침 없이 즉시 추가한다. 5초 폴링은 stream 장애 fallback으로 유지한다. +- 2026-06-25: 지역 분석 리포트와 실거래 상세의 말풍선 버튼에서 `region`/`complex` 채팅 첨부를 만들고, 첨부 카드에는 제목, 설명, 기간 라벨, 등락률 표시값, 내부 복귀 경로를 저장한다. 전송된 첨부 카드는 클릭 시 원래 지역 분석 또는 실거래 상세로 이동한다. +- 2026-06-25: 채팅방 내부 `전체/채팅/뉴스` 필터와 메시지 옆 텍스트 badge를 제거하고, 가입자 작성 메시지는 닉네임 옆 인증 마크로 표시한다. 첨부 카드는 `실거래 실거래`, `지역 분석 최근 1개월`처럼 반복되던 라벨을 제거하고, 마지막 줄을 `최근 n개월 +x.x%` 같은 기간+등락률 줄로 통일한다. 실거래 첨부는 화면 탭의 `MoM`/`YoY` 대신 `최근 1개월`/`최근 1년`처럼 읽히는 기간 라벨을 쓴다. 지역 분석은 설명이 기간과 같으면 숨겨 2줄로 압축한다. +- 2026-06-25: 상단 검색창을 실제 자동완성 입력으로 전환. 2글자 이상 입력 시 target 검색 API를 즉시 8개 제한으로 호출하고, 한 번 누르면 리스트를 닫은 뒤 지역/생활권은 지역 분석 지도 선택 패널, 단지는 실거래 탐색의 첫 매칭 상세 선택 상태로 이동한다. +- 2026-06-25: 상단 검색의 한글 IME 조합 완료 시 정확히 2글자부터 자동완성을 호출하도록 보정하고, 법정동/eupmyeondong 결과는 지역 분석 대신 실거래 필터로 보낸다. 실거래 검색 진입 시 첫 매칭 단지가 열린 상태라면 지도 중심도 해당 단지 좌표로 둔다. +- 2026-06-25: `지역·단지 반응 한눈에` 카드에서 언급 배지, `열기` 메타, 기대/우려 관련 링크, 반응 비율 바를 제거하고 `기대`/`우려` 라벨과 단어 구름을 키워 요약 카드 중심으로 단순화. +- 2026-06-25: `지역·단지 반응 한눈에` 카드를 반응 랭킹이 아니라 주간 상승률 양수 TOP5로 전환하고, 오른쪽 위 상승률 배지와 지역 리포트 기반 기대/우려 키워드 구름을 연결. +- 2026-06-25: TOP5 카드 상승률 배지를 `주간 +x.xx%` 한 줄 배치로 정리하고, 기대/우려 키워드에서 target 지역명, 양쪽 중복어, 짧은 일반어 중복을 제거. +- 2026-06-25: TOP5 카드 단어 구름 위치를 target별 seed 기반으로 분산하고, `전세` 같은 일반어가 `전세 약세` 같은 구체 키워드와 기대/우려 양쪽에 중복 노출되지 않도록 보정. +- 2026-06-25: TOP5 카드 본문을 단어 구름에서 지역 리포트의 `expectationPoints`/`concernPoints` 문장 리스트로 되돌리고, 카드 전체 클릭 시 해당 지역 분석 지도 리포트 패널로 이동하도록 변경. +- 2026-06-25: TOP5 카드의 기대/우려 문장 리스트를 단순 bullet에서 배경 있는 정보 행으로 바꾸고, 행 높이를 고르게 분배해 카드 내부 빈 여백을 줄임. - 2026-06-01: 지도 route와 시도 단위 우선 구현 방침 추가 - 2026-06-01: 대시보드 좌상단 임시 gauge와 핵심 지역별 상승률 chart 기준으로 변경 - 2026-06-01: 주요 지표를 점수형보다 관찰 지표 묶음으로 재정의 diff --git a/docs/layers/ui/screens/realestate-screen-definition.md b/docs/layers/ui/screens/realestate-screen-definition.md index 09e67f7..5e4ffd5 100644 --- a/docs/layers/ui/screens/realestate-screen-definition.md +++ b/docs/layers/ui/screens/realestate-screen-definition.md @@ -12,7 +12,8 @@ ## 2. 공통 화면 원칙 -- 공통 내비게이션은 `대시보드`, `뉴스룸`, `지역 분석`, `실거래`, `주요 일정`, `마이페이지`로 유지한다. +- 공통 내비게이션은 `대시보드`, `뉴스룸`, `지역 분석`, `실거래`, `주요 일정`으로 유지한다. +- 공통 내비게이션 바로 아래의 움직이는 strip은 뉴스룸의 오늘자 뉴스(`feed=news`, KST 발행일 기준), 주요 일정의 오늘 이후 일정, 전국 지도 레이어의 최대 상승 지역과 최대 하락 지역을 함께 노출한다. 뉴스 항목 라벨은 `뉴스`로 통일하고, 별도 `정책`/`리포트` 라벨은 쓰지 않는다. 일정은 `일정`, 지역 등락은 `상승`/`하락` 라벨을 쓴다. 발행일이 오늘로 확인되지 않거나 홍보성 filler인 뉴스는 반복 노출하지 않는다. - 너나사 시리즈의 UI 패턴은 유지하되, 부동산 서비스의 대표 포인트 컬러는 주황 계열을 쓴다. - 상승, 기대, 긍정 방향은 빨강 계열로 표시한다. - 하락, 우려, 냉각 방향은 파랑 계열로 표시한다. @@ -90,7 +91,6 @@ | 실거래 | `/realestate/transactions` | 팀원 구현 전까지 빈 shell만 유지하고, 이후 지역·기간·거래 유형 기준의 실거래/전세 탐색을 붙인다 | transaction fact, filters, summary, evidence | | 주요 일정 | `/indicators`, `/indicators/:category` | 공식 통계·정책·청약·금리 확인 일정 | market data schedules, source links | | 뉴스룸 | `/newsroom?feed=&page=` | 뉴스, 리포트, 영상, 블로그/커뮤니티 원문 모음 | content item, content-target link | -| 마이페이지 | `/realestate/mypage` | 사용자가 저장한 지역을 관리하고 지난 방문 이후 바뀐 시장 사실을 확인 | user watch, alert rule/event, observation log | | 지역/단지 상세 | `/realestate/targets/:targetId` | 특정 지역의 종합 리포트와 근거 확인. 단지는 검증된 경우 보조 상세로 확장 | target, aliases, snapshot, market facts, timeline, evidence | ## 5. 대시보드 정의 @@ -123,10 +123,10 @@ - 블로그·커뮤니티 - 영상과 블로그·커뮤니티에는 순위 번호를 붙이지 않고 일반 콘텐츠 목록으로 표시 - 오른쪽 빠른 패널 - - 인사이트, 거래, 관심 탭 미리보기 - - 인사이트 탭: 지금 확인할 지역 시장 변화와 공개 지연 상태 - - 거래 탭: 최근 실거래/전세 요약과 탐색 진입 - - 관심 탭: 관심 대상 + - 공통 오른쪽 고정 rail은 `관심`, `최근 본`, `채팅` 순서로만 노출 + - `반응`, `실시간` 탭은 제거하고, 공개 반응/뉴스는 본문 카드와 전용 화면에서만 다룸 + - 채팅 탭: 로그인 사용자와 비로그인 게스트 닉네임 기반의 좁은 채팅 피드와 입력창. 사이트 첫 진입 기본값은 채팅 패널이 열린 상태이며, 최근 메시지 조회, SSE 연결, `/api/chat/presence` heartbeat를 바로 시작한다. 처음 열린 채팅 피드는 최신 메시지가 보이도록 하단 위치에서 시작하고, 표시 목록은 최신 100개까지만 유지한다. 사용자가 패널을 닫거나 `관심`/`최근 본`으로 전환하면 채팅 heartbeat를 멈춘다. 채팅방 내부에는 `전체/채팅/뉴스` 필터 탭을 두지 않고 메시지 닉네임 옆에 채팅/뉴스 텍스트 badge를 붙이지 않는다. 참여자 수는 `/api/chat/presence`의 `activeSessionCount`로 받은 최근 45초 채팅 패널 heartbeat 세션 수를 왼쪽 상태값으로 표시한다. 글자 크기/닉네임은 오른쪽 끝 액션 묶음으로 둔다. 로그인 사용자의 닉네임 텍스트를 클릭하면 `닉네임 변경` 팝업에서 채팅 닉네임을 로컬 수정한다. 비로그인 사용자는 블러/모자이크 오버레이에서 `로그인 하기` 또는 `비로그인으로 참여하기`를 선택하고, 게스트 닉네임 입력 후 채팅한다. 로그인/게스트 닉네임은 닉네임 문자열 해시 기반의 고정 색상 팔레트를 사용해 같은 닉네임이면 같은 색상으로 표시하고, 채팅 헤더와 작성자명에는 같은 얇은 하이라이트 띠 오버레이를 얹는다. 가입자 작성 메시지만 인증 마크를 함께 표시하며, 게스트 닉네임은 `/api/chat/nickname-availability`와 `POST /api/chat/messages`에서 기존 가입자 닉네임과 충돌하지 않아야 한다. 메시지는 `/api/chat/messages`로 서버 저장/조회하며, 지역 분석 리포트와 실거래 상세는 채팅 첨부 카드로 함께 저장/조회한다. 첨부 카드 마지막 줄은 기간+등락률로 통일하고, 지역 분석처럼 설명 줄이 기간 라벨과 같으면 해당 설명 줄을 숨긴다 + - 관심 탭: 로그인 사용자 전용 저장 목록. 지역 리포트나 실거래 상세에서 하트로 저장한 `region`/`complex` 관심 대상을 `GET /api/realestate/watch-targets`로 불러오고, 항목 클릭 시 저장된 `landingPath`로 복귀한다. 비로그인 사용자는 로그인 진입만 보여주며 mock 저장 목록을 두지 않는다. ### 5.3 ERD 매핑 @@ -135,7 +135,7 @@ | 오늘의 부동산 브리핑 | `real_estate_market_facts`, `market_indicator_values`, `market_data_schedules`, `content_items` | | 지역 시장 흐름 | `map_layer_snapshots`, `real_estate_market_facts`, `market_indicator_values`, `policy_event_targets` | | 뉴스/리포트/영상/링크 | `content_items`, `content_target_links`, `crawl_sources` | -| 빠른 패널 | `user_watch_targets`, `alert_events`, `real_estate_market_facts`, `market_data_schedules` | +| 빠른 패널 | `app_users`, `user_watch_targets`, `chat_messages`, `alert_events`. WebSocket/STOMP 실시간 전송 테이블은 아직 확정 전 후보 | ### 5.4 API 후보 @@ -149,7 +149,7 @@ - `scheduleSummary`: 이번 주/이번 달 확인할 공식 일정 요약 - `contentFeeds`: 뉴스, 리포트, 영상, 링크 - `dataFreshness`: provider별 stale/mock/asOf -- `quickPanel`: 인사이트/거래/관심 미리보기 +- `quickPanel`: 관심/최근 본 미리보기. 관심은 로그인 세션 기반 `user_watch_targets` 목록을 사용하고, 최근 본은 front 로컬 상태로 표시한다. 사이트 첫 진입에서는 채팅 패널이 기본으로 열려 `GET /api/chat/messages?limit=100`, `/api/chat/messages/stream`, `/api/chat/presence`를 바로 연결하고 최신 메시지가 보이는 하단 위치에서 시작한다. 로그인 사용자와 게스트 닉네임 입력은 `POST /api/chat/messages`로 저장한다. 게스트 메시지는 `session_id`로 같은 브라우저 세션의 내 메시지를 구분하고 인증마크 없이 표시한다. 게스트 닉네임은 저장 전 `GET /api/chat/nickname-availability`로 가입자 닉네임 충돌을 확인하고, 서버 POST도 같은 충돌을 거절한다. 열린 채팅창은 `/api/chat/messages/stream` SSE로 새 메시지를 즉시 추가하며, 주기 재조회는 stream 장애 fallback으로 둔다. 채팅 메시지는 선택적으로 `attachment`를 포함하며, `region`/`complex` 타입, 대상 id, 제목, 보조 설명, 기간 라벨, 등락률 표시값, 내부 복귀 경로를 저장한다. 첨부 카드는 `종류+제목 / 필요할 때만 보이는 설명 / 기간+등락률`로 렌더링한다. 실거래는 지역·동·가격 설명을 유지해 3줄, 지역 분석은 기간 설명이 중복되면 숨겨 2줄로 보인다 ### 5.5 상태 정의 @@ -170,13 +170,14 @@ - 내부 구현명, 지도 asset 출처, TopoJSON 출처 문구는 화면 헤더에 노출하지 않는다. - 헤더 박스는 낮은 높이로 유지하고, 설명은 한 줄 가격·거래 흐름 안내로 제한한다. - 헤더 액션: 기간 탭 1주, 1개월, 3개월, 6개월, 1년 노출. 기본 선택은 1개월로 둔다. 주간 R-ONE 원천이 아직 적재되지 않은 target은 월간 값으로 숨겨서 대체하지 않고 `자료 없음` 또는 `공식 지수 미공표` 상태를 명확히 표시한다. +- 기간 선택은 지도 제목 옆의 compact rectangular segmented control로 표시한다. 별도 `기간` 라벨 버튼은 두지 않고, 5개 기간 옵션은 같은 glass track 안에 둔다. 선택된 기간은 track 안에서 움직이는 밝은 amber slider thumb과 얇은 하단 라인으로 구분한다. 데스크톱에서는 `전국 흐름 지도` 라벨과 `전국 부동산 흐름` 제목 묶음의 전체 높이를 기준으로 세로 중앙에 둔다. 모바일에서는 제목 아래로 내려가며 한 줄 안에서 viewport 폭에 맞춘다. - 지도 박스 우측에는 `UPDATE: YYYY.MM.DD` 형식의 낮은 강조 회색 배지를 둔다. 날짜는 현재 선택한 기간의 공식 period `asOf`를 우선하고, 없으면 지도 layer `asOf`로 fallback한다. - 헤더에는 `공공데이터 반영 · 최신 반영 · 기준 시각`처럼 긴 상태 배지를 노출하지 않는다. - 지도 본문: 시도별 3D heat layer - 지도 stage의 보조 라벨과 제목은 `전국 흐름 지도`, `전국 부동산 흐름`, `지역 상세 지도`, `{지역명} 상세 흐름`처럼 사용자용 한국어로 표시한다. - 범례: 상승 빨강, 하락 파랑, 공식 지수 미공표는 검정에 가까운 회색 - 지역 버튼: 각 시도 중심점에 표시 -- 하단 요약: 가장 강한 지역, 하위 지역 수, 다음 단계 +- 하단 요약: 가장 높은 상승, 가장 많은 하락, 지역 단위. `한국부동산원 R-ONE · 공공데이터 반영 · 최신 반영 · 기준 시각` 같은 긴 출처/상태 문자열은 하단 요약에 노출하지 않는다. 동작: @@ -220,12 +221,12 @@ API: - 지도 확대 시 라벨은 지도 확대 배율과 반대로 보정해 글자가 과도하게 커지거나 흐려지지 않게 한다. - 경기도처럼 하위 시군구가 많아 라벨이 한 화면에 몰리는 지역은 처음부터 모든 시군구 버튼을 노출하지 않고 `북부`, `서부`, `중부`, `남부` 같은 권역 클러스터를 먼저 보여준다. `동부 1곳`처럼 단독 권역이 생기면 중부 등 인접 권역에 통합한다. 클러스터 초기 화면에서는 땅 polygon 클릭으로 개별 시군구 선택이 열리지 않아야 하며, 클러스터 버튼을 누를 때만 해당 권역 feature만 다시 투영한 권역 상세 지도로 들어간다. 그 안에서 개별 시군구 버튼을 더 크게 노출한다. - 인천처럼 섬과 본토 간 거리가 커서 본토가 한쪽에 몰리는 지역은 행정구역 전체 shape을 억지로 늘리거나 찌그러뜨리지 않는다. 상세 지도에서는 과도하게 먼 원거리 섬 polygon 일부만 생략하고, 남은 본토/주요 섬의 실제 위치 관계를 유지한다. 하위 지역 버튼/라벨은 수동 배치하지 않고 각 구·군 경계 내부 중심점에 둔다. -- 하단 요약: 가장 강한 하위 지역, 하위 지역 수, 다음 단계 +- 하단 요약: 가장 높은 상승, 가장 많은 하락, 하위지역. `다음 단계` 카드는 두지 않고, 지역 단위 카드는 하위지역 개수와 `하위지역 기준`만 표시한다. - 선택 리포트 패널 - 지역 리포트 헤더 - AI 핵심 브리핑. 선택한 하위 지역 `targetId` 기준 `GET /api/realestate/targets/{targetId}/evidence-logs?limit=1`을 조회하되, 첫 화면에는 raw confidence, `AI 근거`, `insufficient` 같은 내부 상태어를 노출하지 않는다. 없으면 `분석 근거 수집 전`처럼 사용자용 상태로 바꾼다. - 선택 기간 등락률: 지도에 숫자를 직접 올리지 않고, `AI 분석 리포트` 본문 첫머리의 큰 수치 박스로 표시한다. 본문은 이 박스 오른쪽에서 시작하고 문장이 길어지면 박스 아래로 자연스럽게 이어진다. 박스 내부는 `최근 1개월`, `최근 1년`처럼 현재 선택 기간과 큰 등락률 숫자만 표시하고, 하단 보조 라벨은 두지 않는다. 기간 탭은 숫자와 지도 색상 렌즈만 바꾸며 리포트 본문 자체를 새로 만들지 않는다. - - 기대 지점/우려 지점: 대표 브리핑 카드 본문 전체 폭을 써서 각각 3줄로 보여준다. 대시보드의 지역·단지 반응 구분을 참고하되, 행동 권유가 아니라 공식 지표와 수집 상태 기준의 관찰 포인트로만 쓴다. 오른쪽 패널에서 읽기 편하도록 본문 글씨는 작은 보조 텍스트처럼 보이지 않게 한다. + - 기대 지점/우려 지점: 대표 브리핑 카드 본문 전체 폭을 써서 각각 1-2개만 보여준다. 의미 있는 판단 근거가 없으면 억지로 3개를 채우지 않는다. 행동 권유가 아니라 공식 지표, 전세, 실거래, 공급, 정책 일정 기준의 관찰 포인트로만 쓴다. 오른쪽 패널에서 읽기 편하도록 본문 글씨는 작은 보조 텍스트처럼 보이지 않게 한다. - AI 분석 리포트: 선택 기간마다 다른 리포트를 만들지 않고, 최신 기준의 종합 평가와 판단을 짧은 본문으로 표시한다. 우측 날짜는 `리포트 업데이트 {시각}`으로 표시하고, 선택 기간의 `asOf`가 아니라 최신 EvidenceLog 또는 고정 리포트 기준일을 사용한다. 선택 기간 등락률은 본문을 보조하는 숫자 렌즈다. - 관련 뉴스·리포트: 대시보드 전체 브리핑 하단처럼 가격지수, 실거래, 뉴스/리포트 원문 근거가 적재되는 ledger로 둔다. 원문이 없으면 `수집 전`, `공식 데이터 없음`, `원문 링크 적재 대기` 상태로 둔다. - 지역 내 비교: 시도에서는 하위 시군구, 시군구에서는 생활권/동/단지 후보를 데이터 있는 항목만 표시 @@ -233,7 +234,7 @@ API: - 근거 로그와 한계: 첫 화면에서는 기대/우려, AI 분석 리포트, 관련 뉴스·리포트 상태에 흡수하고, 상세 확장에서는 출처, 기준 시각, 갱신 상태, 상위 지역만 반영 여부, 분석 근거 상태를 더 자세히 보여준다. - 보조 반응: 공개 원문/커뮤니티 반응은 하단 참고 신호로만 표시 - 단지 좌표 매핑 후보. 없으면 `수집 전` 상태 유지 - - 시각 구조: `/realestate/daily-briefing` 상세 화면의 narrative panel과 관련 근거 ledger 리듬을 참고하되 동일 컴포넌트처럼 보이지 않게 한다. 처음 보이는 오른쪽 패널은 `AI 핵심 브리핑/기대·우려`, `AI 분석 리포트/선택 기간 등락률`, `관련 뉴스·리포트`만 보여준다. 대표 브리핑 카드는 라벨과 제목을 촘촘한 한 묶음으로 두고, 아래 본문 전체 폭에 기대 지점과 우려 지점을 나란히 배치한다. 등락률은 분석 리포트 본문 첫머리의 큰 수치 박스로 두며, 지역 내 비교/공급·정책·보조 반응/단지 후보는 후속 상세 후보로 둔다. + - 시각 구조: `/realestate/daily-briefing` 상세 화면의 narrative panel과 관련 근거 ledger 리듬을 참고하되 동일 컴포넌트처럼 보이지 않게 한다. 처음 보이는 오른쪽 패널은 `AI 핵심 브리핑/기대·우려`, `AI 분석 리포트/선택 기간 등락률`, `관련 뉴스·리포트`만 보여준다. 대표 브리핑 카드는 라벨만 두고, 리포트 headline 문장은 이 영역에 반복하지 않는다. 아래 본문 전체 폭에 기대 지점과 우려 지점을 나란히 배치한다. 등락률은 분석 리포트 본문 첫머리의 큰 수치 박스로 두며, 지역 내 비교/공급·정책·보조 반응/단지 후보는 후속 상세 후보로 둔다. 동작: @@ -242,8 +243,9 @@ API: - 하위 지역을 클릭하면 지도는 왼쪽으로 줄고 오른쪽에 리포트가 나타난다. - 리포트는 갑작스러운 밀림보다 투명도 기반으로 부드럽게 나타난다. - 리포트 닫기 후 다시 중앙 지도 상태로 돌아간다. -- 리포트는 새 report 테이블을 전제로 하지 않는다. UI 우선 단계에서는 지도 layer, target market facts, timeline/content, 최신 EvidenceLog를 조합해 보여주고, backend 후속 단계에서 기존 EvidenceLog의 evidence item을 `market_fact`, `timeline_event`, `content`, `schedule`, `similar_window`, `reaction`으로 구분해 보강한다. -- 화면 요청 중 AI를 호출하지 않는다. batch/pipeline이 만든 최신 EvidenceLog를 조회하며, 값이 없으면 `공식 데이터 없음`, `수집 전`, `상위 지역만 반영`, `공개 지연 가능` 상태로 둔다. +- 리포트 본문, 기대 지점, 우려 지점, 근거 적재소는 `GET /api/realestate/targets/{targetId}/regional-report`의 최신 저장본을 우선 사용한다. 지도 기간 탭은 색상과 등락률 숫자만 바꾸며 저장 리포트 본문을 기간별로 다시 쓰지 않는다. +- 저장 리포트가 없으면 지도 layer, target market facts, timeline/content, 최신 EvidenceLog를 조합한 fallback을 보여준다. 이때도 값이 없으면 `공식 데이터 없음`, `수집 전`, `상위 지역만 반영`, `공개 지연 가능` 상태로 둔다. +- 화면 요청 중 AI를 호출하지 않는다. AI API 자동화는 backend/pipeline이 `POST /internal/realestate/regional-reports`로 같은 target의 최신 리포트를 교체하는 방식으로 붙인다. ERD 매핑: @@ -254,12 +256,19 @@ ERD 매핑: - `real_estate_market_facts`: 실거래/전세/매물 fact - `timeline_events`, `policy_event_targets`: 정책/교통 이슈 - `real_estate_complexes`: 다음 단계 단지 좌표 연결 후보 +- `real_estate_regional_reports`, `real_estate_regional_report_sources`: 지도 기간과 무관하게 target별 최신 리포트 1개와 근거 ledger API: - `GET /api/realestate/map/layers?layerType=sigungu&parentTargetId={targetId}` +- `GET /api/realestate/targets/{targetId}/regional-report` - `parentTargetId`는 시도 target id를 사용한다. 예: `region-seoul` - 응답 shape는 전국 지도와 같고, `targets[]`는 해당 시도 하위 시군구 target이다. +- 지역 리포트 응답은 `reportId`, `targetId`, `targetName`, `regionLevel`, `regionCode`, `title`, `headline`, `summary`, `body`, `expectationPoints[]`, `concernPoints[]`, `dataQuality`, `confidence`, `asOf`, `publishedAt`, `sources[]`를 포함한다. +- `expectationPoints[]`와 `concernPoints[]`는 화면에서 문장 설명처럼 보이지 않도록 `전세·실거래 동행`, `입주 물량 부담` 같은 짧은 명사형만 사용한다. 3개를 억지로 채우지 않고 1-2개를 기본으로 한다. +- `body`는 금융권 리포트형 문체로 `평가:`와 `전망:`을 포함한다. `평가:`는 해당 지역의 수요·공급·전세·정책·산업 변수를 해석하고, `전망:`은 `관망 우위`, `선별 관망`, `선별 회복 가능성`, `방어력 우위`처럼 시장 방향성 관찰을 드러낸다. 화면에서는 `평가`와 `전망` 라벨을 굵게 표시하고 각 문단을 명확히 줄바꿈해 한 문단처럼 붙어 보이지 않게 한다. 매수·매도·청약·대출 행동 권유는 아니다. +- 하위 시군구 리포트는 상위 시도 리포트 문장을 재사용하지 않는다. 같은 광역권 안에서도 생활권, 산업축, 전세 방어, 공급 부담이 다르면 기대/우려 포인트와 본문 판단을 각각 다르게 저장한다. +- `sources[]`는 근거 적재소에 표시하며 `url`이 있으면 원문 링크로 열고, 없으면 수집 전/확인 필요 row로 남긴다. - 1차 구현은 DB snapshot이 있는 하위 지역만 실제 API 값으로 덮어쓴다. `seed/mock` snapshot은 가격지도 API에서 제외되며, 공식 가격지수나 실거래 fact가 없는 지역은 검정에 가까운 회색 `공식 지수 미공표` 상태로 남긴다. 이 지역의 버튼은 disabled 상태로 두고 클릭해도 오른쪽 지역 리포트를 열지 않는다. - 하위 snapshot이 없으면 기존 도식화 topology fallback을 유지하고, 하단 상태에 `하위 레이어 fallback`을 표시한다. - 하위 snapshot이 있으면 하단 상태에 대표 period의 `provider`, `dataStatus`, `fresh/stale`, `asOf`를 그대로 표시한다. `DB snapshot`처럼 출처와 기준 시각을 숨기는 축약 문구만 단독으로 쓰지 않는다. @@ -412,69 +421,15 @@ Route: `/newsroom?feed=all|news|reports|videos|links&page=` - `GET /api/realestate/content-items/:id` - `GET /api/realestate/targets/:targetId/content?feed=` -## 10. 마이페이지 화면 정의 - -Route: `/realestate/mypage` - -레거시 북마크 호환을 위해 `/realestate/watchlist`는 `/realestate/mypage`로 redirect한다. - -### 10.1 목적 - -사용자가 저장한 지역/단지의 시장 변화, 주요 일정, 알림 조건, 개인 메모, 저장 지역 비교를 확인한다. 로그인/사용자 저장 API가 열리기 전에는 실제 저장 목록처럼 보이는 mock watchlist를 두지 않고 준비 상태를 분리해서 보여준다. - -### 10.2 현재 화면 구성 - -- 마이페이지 요약 상태 - - 저장 지역/단지 - - 지난 방문 이후 바뀐 것 - - 알림 조건 - - 관찰 메모 -- 내 저장 지역/단지 - - 로그인 전에는 실제 저장 목록처럼 보이는 mock 목록을 두지 않고 빈 상태를 표시 - - 저장 이유 태그 후보: 실거주, 전세, 청약, 투자관찰, 교통, 재건축, 공급 -- 지난 방문 이후 바뀐 것 - - 새 실거래/전세 거래 - - 주요 일정 도래 - - 정책/공급/청약 이슈 추가 - - 근거 리포트 갱신 - - 데이터 stale 또는 공식 데이터 없음 상태 -- 내 알림 조건 - - `알림 조건 준비 중` 또는 `조건 관리` 상태로 표시 -- 지역별 관찰 메모 - - 개인 기록으로만 표현하고 투자 행동을 권유하지 않음 -- 저장 지역 비교 - - 실거래, 전세, 공급, 주요 일정, 근거 갱신 상태를 저장 지역끼리 비교 - -### 10.3 ERD 매핑 - -- `app_users`: 사용자 -- `user_watch_targets`: 관심 대상 저장 -- `alert_rules`: 알림 조건 -- `alert_events`: 발생한 알림 -- `observation_logs`: 관찰/복기 로그 -- `real_estate_aliases`: 별칭 DB 연결 준비 -- `crawl_sources`, `source_boards`: 원문 수집 후보 -- `real_estate_market_facts`: 공공데이터 후보 - -### 10.4 API 후보 - -- `GET /api/users/me/watch-targets` -- `POST /api/users/me/watch-targets` -- `GET /api/users/me/alerts` -- `GET /api/users/me/observation-logs` -- 저장 기능 전 임시 후보: `GET /api/realestate/transactions/summary?windowMinutes=10080&limit=10` - -현재 로그인은 화면상 버튼만 존재한다. 실제 사용자 기능이 열리기 전에는 저장된 관심 목록처럼 보이는 mock watchlist를 두지 않고, 저장 대상 없음/로그인 연동 준비 중/데이터 확인 전 상태를 분리해 표시한다. API가 비거나 실패하면 `데이터 확인 전/insufficient` 또는 오류 상태를 표시한다. - -## 11. 지역/단지 상세 화면 정의 +## 10. 지역/단지 상세 화면 정의 Route: `/realestate/targets/:targetId` -### 11.1 목적 +### 10.1 목적 특정 지역/단지에 대해 어떤 시장 사실이 바뀌었는지, 어떤 지표와 원문이 근거인지, 신뢰도와 데이터 지연 상태는 어떤지 리포트 형태로 보여준다. -### 11.2 현재 화면 구성 +### 10.2 현재 화면 구성 - 지역 한줄 브리핑 - 지역 상세 리포트 헤더 @@ -522,7 +477,9 @@ Route: `/realestate/targets/:targetId` ## 12. 공통 컴포넌트 정의 -공통 shell의 상단 인사이트 strip과 오른쪽 rail은 정적 mock 문구를 두지 않는다. 로그인/사용자 저장 기능이 열리기 전에는 `GET /api/realestate/transactions/summary?windowMinutes=10080&limit=6` 또는 dashboard summary 응답을 우선 사용하고, 응답이 비면 `데이터 확인 전/insufficient`, 실패하면 `시장 데이터 API 오류`를 표시한다. +공통 shell의 상단 인사이트 strip과 오른쪽 rail은 정적 mock 문구를 두지 않는다. 상단 인사이트 strip은 `GET /api/realestate/newsroom?feed=news&page=1&pageSize=12`, `GET /api/realestate/market-data-schedules?month=YYYY-MM`, `GET /api/realestate/map/layers?layerType=sido` 응답을 조합해 오늘자 뉴스, 다가오는 일정, 최대 상승/하락 지역을 반복 노출하고, 뉴스룸/일정/지도 배치 갱신 이벤트가 오면 다시 불러온다. 오른쪽 rail의 관심 탭은 `JSESSIONID` 로그인 사용자만 사용하며, `GET /api/realestate/watch-targets` 응답이 비면 저장된 관심이 없다고 표시하고 실패하면 관심 목록 오류를 표시한다. 비로그인 상태에서는 저장 목록처럼 보이는 fallback을 두지 않는다. + +오른쪽 고정 rail의 탭은 `관심`, `최근 본`, `채팅` 순서로 제한한다. `반응`, `실시간` 탭은 노출하지 않는다. rail 하단 `다크모드` 버튼은 shell, 본문, 우측 rail, 채팅창의 색상 테마를 전환하고 브라우저 저장값으로 다음 방문에도 유지한다. 사이트 첫 진입 기본값은 채팅 패널이 열린 상태이며, 최근 메시지 조회, SSE 연결, `/api/chat/presence` heartbeat를 바로 시작하고 채팅 피드는 최신 메시지가 보이는 하단 위치에서 시작한다. 사용자가 패널을 닫거나 `관심`/`최근 본`으로 전환하면 채팅 heartbeat를 멈춘다. 채팅 탭은 알파벳이 아니라 가로형 말풍선 아이콘으로 표시하고, 로그인 사용자는 `JSESSIONID` 사용자 `displayName`, 비로그인 사용자는 게스트 닉네임을 보여준다. 채팅방 내부에는 `전체/채팅/뉴스` 필터 탭을 두지 않고, 메시지 닉네임 옆에는 채팅/뉴스 텍스트 badge 대신 가입자 인증 마크만 조건부로 표시한다. 로그인/게스트 닉네임은 닉네임 문자열 해시 기반의 고정 색상 팔레트를 사용해 같은 닉네임이면 같은 색상으로 표시한다. 로그인/게스트 닉네임 모두 채팅 헤더와 메시지 작성자명에서 해당 고정 색상 텍스트를 기본으로 유지하고, 천천히 흐르는 얇은 하이라이트 띠 오버레이만 얹는다. 로그인한 내 새 메시지는 응답에서 인증 필드가 잠깐 빠져도 가입자 스타일을 유지한다. 게스트 닉네임은 인증마크 없이 같은 색상/하이라이트 스타일만 적용하고, 입력 시 기존 가입자 닉네임과 충돌하면 저장하지 않는다. 메시지는 `chat_messages`에 저장하고 `GET /api/chat/messages?limit=100`으로 최근 피드를 읽으며, 프론트 표시 목록도 최신 100개까지만 유지한다. 로그인 사용자와 게스트 입력은 `POST /api/chat/messages`로 등록한다. 게스트 닉네임 입력은 `GET /api/chat/nickname-availability`로 가입자 닉네임 충돌을 먼저 확인하고, `POST /api/chat/messages`도 비로그인 충돌을 `409`로 거절한다. 열린 채팅창은 `/api/chat/messages/stream` SSE(EventSource)를 유지하고, 서버가 `chat-message` 이벤트를 보내면 새로고침 없이 피드에 추가한다. 참여자 수는 버튼이 아니라 초록 점과 숫자가 같은 줄에 놓이는 상태 표시값으로 왼쪽에 두고, `/api/chat/presence`의 `activeSessionCount` 기반 최근 45초 채팅 패널 heartbeat 세션 수를 표시한다. 글자 크기와 닉네임은 오른쪽 끝 액션 묶음으로 둔다. 로그인 사용자는 닉네임 텍스트 클릭 시 `닉네임 변경` 제목이 있는 팝업에서 채팅 닉네임을 로컬 수정하며, 비로그인 사용자는 블러/모자이크 오버레이에서 `로그인 하기` 또는 `비로그인으로 참여하기`를 선택하고 닉네임 입력 후 인증마크 없이 채팅한다. `닉네임 설정` 문구 버튼, 톱니, 팝업, 닫기 버튼은 두지 않는다. 채팅 패널은 본문 배경과 구분되는 블루그레이 톤을 사용하고, 본문과의 경계에는 두꺼운 왼쪽 선과 그림자를 둔다. 내가 쓴 메시지는 오른쪽 정렬의 옅은 배경 말풍선으로 구분한다. 지역 리포트와 실거래 상세의 말풍선 버튼은 현재 대상을 채팅 입력창에 첨부 카드로 올리고, 전송된 첨부 카드는 `종류+제목 / 필요할 때만 보이는 설명 / 기간+등락률`로 압축한다. 실거래는 지역·동·가격 설명을 남겨 3줄, 지역 분석은 기간 설명 중복을 접어 2줄로 보이며, 클릭 시 해당 지역 분석 또는 실거래 상세 URL로 이동한다. WebSocket/STOMP 확장은 별도 contract 후보로 둔다. ### 12.1 상태 배지 @@ -580,9 +537,7 @@ ERD 후보: - 공식 통계·정책·청약·금리 확인 일정을 캘린더와 출처 링크로 정리한다. 5. 뉴스룸 - 시장 fact와 일정의 근거 링크를 확인하는 보조 탐색 화면이다. -6. 마이페이지 - - 사용자가 저장한 지역을 관리하고, 지난 방문 이후 바뀐 시장 사실을 확인하는 개인화 공간으로 둔다. -7. 지역/단지 상세 +6. 지역/단지 상세 - 지도와 실거래에서 클릭해 들어가는 최종 리포트다. ## 14. 구현 체크리스트 @@ -595,7 +550,6 @@ ERD 후보: - [ ] 상승 빨강, 하락 파랑 색상 토큰 공통화 - [ ] 뉴스/리포트/보조 공개 원문 링크의 출처 icon fallback 정리 - [ ] 지역/단지 상세의 evidence log 구조 확정 -- [ ] 마이페이지는 로그인 전 준비 상태와 로그인 후 실제 사용자 저장 상태 분리 ## 15. 아직 결정이 필요한 부분 diff --git a/docs/layers/ui/screens/realestate-target-detail.md b/docs/layers/ui/screens/realestate-target-detail.md index 1608292..58010d7 100644 --- a/docs/layers/ui/screens/realestate-target-detail.md +++ b/docs/layers/ui/screens/realestate-target-detail.md @@ -54,7 +54,7 @@ - 클릭성: 지도는 화면에서 충분히 크게 보이도록 세로 stage를 키우고, 지역별 hit area를 실제 도형보다 넓게 둠 - 확대/이동: 전국 지도와 시군구 상세 지도는 모두 마우스 휠 확대/축소와 드래그 이동을 지원합니다. 경기처럼 하위 지역이 밀집된 지도를 확대해서 개별 지역을 선택할 수 있어야 합니다. - heat 표현: 빨강=상승, 파랑=하락, 변화폭이 클수록 더 진하고 밝게 표현해 한눈에 강약을 구분 -- 리포트 밀도: 선택 지역 리포트는 `/realestate/daily-briefing` 상세 화면의 narrative panel과 관련 근거 ledger 리듬을 참고하되 그대로 복제하지 않는다. 처음 보이는 오른쪽 패널은 `AI 핵심 브리핑`, `기대 지점 3줄`, `우려 지점 3줄`, `AI 분석 리포트`, `선택 기간 등락률`, `관련 뉴스·리포트`로 구성한다. 상승·하락률 숫자는 지도 위가 아니라 `AI 분석 리포트` 본문 첫머리의 큰 수치 박스로 표시하고, 본문은 박스 오른쪽에서 시작해 길어지면 박스 아래로 이어진다. 수치 박스는 선택 기간 라벨과 큰 등락률 숫자만 남기고 하단 보조 라벨을 제거해 숫자 비중을 높인다. 대표 브리핑 카드 본문 전체 폭은 대시보드의 지역·단지 반응처럼 기대/우려를 나누되, 결론이나 행동 권유가 아니라 확인 가능한 관찰 포인트로만 쓴다. 하단 `관련 뉴스·리포트`는 브리핑 본문을 반복하지 않고 가격지수, 실거래, 뉴스/리포트 원문 근거가 쌓이는 자리로 둔다. +- 리포트 밀도: 선택 지역 리포트는 `/realestate/daily-briefing` 상세 화면의 narrative panel과 관련 근거 ledger 리듬을 참고하되 그대로 복제하지 않는다. 처음 보이는 오른쪽 패널은 `AI 핵심 브리핑`, `기대 지점`, `우려 지점`, `AI 분석 리포트`, `선택 기간 등락률`, `관련 뉴스·리포트`로 구성한다. 기대/우려는 의미 있는 명사형 포인트 1-2개만 노출하고 3개를 억지로 채우지 않는다. 상승·하락률 숫자는 지도 위가 아니라 `AI 분석 리포트` 본문 첫머리의 큰 수치 박스로 표시하고, 본문은 박스 오른쪽에서 시작해 길어지면 박스 아래로 이어진다. `평가`와 `전망` 라벨은 굵게 표시하고 각 문단은 명확히 줄바꿈한다. 수치 박스는 선택 기간 라벨과 큰 등락률 숫자만 남기고 하단 보조 라벨을 제거해 숫자 비중을 높인다. 대표 브리핑 카드 본문 전체 폭은 대시보드의 지역·단지 반응처럼 기대/우려를 나누되, 결론이나 행동 권유가 아니라 확인 가능한 관찰 포인트로만 쓴다. 하단 `관련 뉴스·리포트`는 브리핑 본문을 반복하지 않고 가격지수, 실거래, 뉴스/리포트 원문 근거가 쌓이는 자리로 둔다. - 단지 단위: `/realestate/targets/:targetId` 상세 화면에 카카오맵 SDK 내장 prototype을 붙이고, SDK 비활성화/key missing/test 환경에서는 도식화 fallback으로 marker와 선택 패널을 보여줌 - 내장 지도: 카카오맵 SDK key는 `front/.env.local`의 `VITE_KAKAO_JAVASCRIPT_KEY`에서만 읽고, key missing 상태에서는 `지도 SDK 대기`와 provider/asOf/stale 상태를 표시 - 단지 marker: `GET /api/realestate/targets/{targetId}/nearby-complexes`를 우선 사용하고 실패/빈 응답이면 후보 좌표로 fallback. `targetId`, 단지명, 주소, 좌표, 가격 흐름, 거래 요약, `provider/asOf/dataStatus/stale`을 함께 표시하며, seed 좌표는 `좌표 검증 전`으로 드러냄 @@ -113,6 +113,7 @@ - target별 market fact가 부족하지만 전국 공식 지표가 있는 경우 EvidenceLog는 `national_market_fact` 근거를 함께 포함합니다. 화면은 이를 지역별 가격 사실처럼 단정하지 않고 `전국 지표만 반영` 주의 사항과 함께 보여줍니다. - EvidenceLog의 `evidenceItems[]` 중 `sourceUrl`이 있는 항목은 AI 근거 로그 안에서도 외부 근거 링크로 렌더링합니다. 링크 칩에는 label, valueText, `sourceId`, `sourceDataStatus`, 발행 시각을 함께 표시해 SerpApi 후보가 `candidate`인지 숨기지 않습니다. - 프론트 상세 화면은 route의 `targetId`를 그대로 backend API에 넘깁니다. 예시는 `region-seoul-mapo`, `living-area-gyeonggi-dongtan-station`처럼 `real_estate_targets.id`와 같은 값입니다. API 응답이 있으면 근거 링크 후보를 승인된 content row로 교체하고, 없거나 실패하면 `원문 확인 필요` 또는 `수집 전` 상태로 둡니다. +- 지도 오른쪽 지역 리포트는 지역명 바로 오른쪽에 하트 버튼을 둡니다. 로그인 사용자는 이 버튼으로 `region` 관심 대상을 저장/해제하고, 저장된 항목은 오른쪽 `관심` 탭에서 같은 지도 리포트 `landingPath`로 복귀합니다. 비로그인 사용자가 누르면 로그인 화면으로 이동합니다. - TOP10에 오른 `region-seoul` 같은 target이 아직 상세 fixture에 없더라도, route `targetId`로 `evidence-logs` API를 조회해 AI 근거 로그를 먼저 보여줍니다. 로그가 있으면 unsupported가 아니라 `실시간 근거 리포트` 상태로 전환하고, 잘못된 지역 mock 리포트로 대체하지 않습니다. - 단지 위치 레이어 1차 구현은 `region-seoul-mapo`, `living-area-gyeonggi-dongtan-station`, `complex-mapo-raemian-prugio`에서 후보 marker를 사용합니다. 실제 좌표 DB/API 연결 전까지 marker `dataStatus`는 `좌표 검증 전`으로 표시합니다. - content row는 원문 전문을 복제하지 않고 제목, 출처, URL, 발행일/metric label만 근거 링크 카드에 표시합니다. @@ -142,6 +143,8 @@ - 2026-06-16: 지도 drilldown 진입 시 부모 시도 EvidenceLog 리포트를 기본 표시하고, 하위 시군구 클릭 시 같은 패널이 선택 지역 리포트로 교체되도록 상호작용 기준을 갱신. - 2026-06-24: 대시보드 주간 상승/하락 카드처럼 외부 카드에서 들어오는 `/realestate/map/:regionId?selectedTargetId=:targetId&selectedRegionCode=:geometryCode&period=week` query를 지원해 해당 시군구 지도+리포트 패널을 바로 열도록 기준 추가. - 2026-06-24: 지도 오른쪽 리포트는 새 report 테이블 없이 기존 EvidenceLog를 확장 입력으로 쓰며, 상승·하락률을 `AI 분석 리포트` 본문 첫머리의 큰 수치 박스로 표시한다. 변화율 라벨은 선택 기간만 한 번 노출하고, 하단 보조 라벨 없이 큰 숫자 중심으로 둔다. 본문은 박스 오른쪽에서 시작해 박스 아래로 자연스럽게 이어지게 둔다. 대표 카드 본문 전체 폭에는 기대 지점/우려 지점을 각각 3줄로 둔다. 본문은 최신 기준 `AI 분석 리포트`로 고정하고, 하단은 `관련 뉴스·리포트` 근거 적재소로 둔다. +- 2026-06-25: 지역 리포트명 바로 오른쪽 하트로 로그인 사용자 기반 `region` 관심 저장/해제를 지원하고, 오른쪽 `관심` 탭에서 저장된 리포트 경로로 복귀하는 기준 추가. +- 2026-06-25: 지역 리포트명 하트 옆 말풍선 버튼으로 선택 지역을 채팅 입력창에 첨부한다. 첨부에는 지역명, 현재 분석 기간, 등락률, 선택 리포트 복귀 URL을 포함하고, 채팅 카드 클릭 시 해당 지역 분석 리포트로 이동한다. - 2026-06-24: 기대/우려와 AI 분석 리포트 본문 글씨를 키워 오른쪽 50% 패널에서도 읽기 편하게 조정한다. - 2026-06-24: 지도 오른쪽 리포트 첫 화면에서 raw confidence, 내부 근거 상태어, `insufficient` 노출을 제거하고, 날짜는 선택 기간별 기준일이 아니라 `리포트 업데이트` 시각으로 통합한다. - 2026-06-16: 지역별 market fact가 부족한 target은 한국부동산원 전국 지표를 배경 근거로 붙이되 `전국 지표만 반영` 상태를 표시하는 기준 추가. diff --git a/docs/layers/ui/screens/transaction-explorer.md b/docs/layers/ui/screens/transaction-explorer.md index b2d5a19..2accf51 100644 --- a/docs/layers/ui/screens/transaction-explorer.md +++ b/docs/layers/ui/screens/transaction-explorer.md @@ -18,10 +18,12 @@ ## 현재 섹션 -- 현재 프론트는 팀원 구현 전 빈 shell만 유지합니다. - 네비게이션 문구는 `실거래`, 정본 route는 `/realestate/transactions`로 둡니다. - 기존 `/realestate/reactions`는 북마크 호환용 redirect로만 둡니다. -- 필터, 테이블, 거래 row, 상세 진입 UI는 이 문서의 API 후보만 참고하고 아직 화면에 만들지 않습니다. +- 상단 필터바: 지역, 정렬, 매물 유형, 거래 방식, 검색, 가격 비교 기간을 제공합니다. 기본 진입값은 `아파트`, `전체`, `MoM`입니다. +- 상단 필터바에는 `국토교통부 실거래`, `실좌표 n/n` 같은 provider/좌표 상태 pill을 두지 않습니다. +- 지도 stage: 실좌표가 확인된 거래 후보 marker를 보여주고, 좌측에는 실거래 목록 overlay를 둡니다. +- 상세 패널: marker나 목록 row를 선택하면 거래 요약, 비교 기간별 가격 흐름, 주변 시설, provider 상태를 보여줍니다. 단지명 바로 오른쪽에는 로그인 사용자용 하트 버튼을 둬 `complex` 관심 저장/해제를 지원하고, 그 옆 말풍선 버튼은 현재 단지를 채팅 입력창에 `complex` 첨부 카드로 올립니다. 커뮤니티 반응, 관련 뉴스/콘텐츠, 정책 타임라인은 이 패널에서 조회하거나 표시하지 않습니다. 선택한 항목은 `/realestate/transactions?region=:legalDongCode&property=:propertyType&deal=:dealType&selected=:transactionId` 형태의 복귀 URL로 오른쪽 `최근 본` 탭과 채팅 첨부 카드에 기록하고, 관심 저장 항목은 `/realestate/transactions?region=:legalDongCode&selected=:transactionId` 복귀 URL로 오른쪽 `관심` 탭에 기록합니다. 상세 패널이 열린 상태에서는 지도 중심을 선택 항목 좌표로 두며, route `q` 검색 진입으로 자동 선택된 첫 결과도 같은 기준을 적용합니다. 상세 비교 기간은 패널을 열 때 상단 가격 비교 기간을 한 번 복사하지만, 이후 상세 패널 안에서 바꿔도 상단 지도/목록 비교 기간은 바꾸지 않습니다. ## 상태와 빈 화면 @@ -55,6 +57,15 @@ - `GET /api/realestate/transactions?regionId=&tradeType=&from=&to=&priceMin=&priceMax=&areaMin=&areaMax=&page=&pageSize=` - `GET /api/realestate/transactions/summary?regionId=&tradeType=&from=&to=` +- `POST /api/realestate/watch-targets` / `DELETE /api/realestate/watch-targets?targetType=complex&targetId=`: 로그인 사용자의 실거래 상세 관심 저장/해제 + +오른쪽 `최근 본` 복귀용 query: + +- `region`: 법정동/시군구 코드. 없으면 기본 지역으로 진입합니다. +- `property`: `apt`, `offi`, `rh`, `silv`, `sh` 중 하나. 없으면 `apt`를 사용합니다. +- `deal`: `trade`, `rent`, `all` 중 하나. 최근 본 부동산 링크는 실제 선택 항목의 거래 유형을 넣습니다. +- `selected`: 실거래 목록에서 선택했던 `TransactionItem.id`. 목록 로드 후 같은 id가 있으면 상세 패널을 바로 엽니다. +- `q`: 대시보드 검색에서 넘어온 단지명/동명 필터. 목록 로드 후 첫 매칭 항목이 있으면 상세 패널을 바로 열고 지도 중심을 해당 좌표로 둡니다. ## 결정된 기준과 확인 필요 @@ -67,3 +78,16 @@ - 2026-06-22: `지역 반응` 정본 화면을 내리고, 실거래/전월세 공식 데이터 기반 탐색 화면으로 전환. - 2026-06-22: 팀원 구현 전까지 페이지 내부 UI 없이 빈 shell만 제공하고, 네비게이션 문구는 `실거래`로 확정. +- 2026-06-24: 실거래 지도 상단의 provider/좌표 상태 pill을 제거하고 필터 조작만 남김. +- 2026-06-24: 지역 선택 목록은 DB `real_estate_regions` 중 9개 MOLIT 실거래 market-data target이 모두 있는 시군구 275개를 기준으로 생성한다. +- 2026-06-25: 지역 선택 UI는 시도 select와 시군구 select를 분리하고, 시도 변경 시 해당 시군구 목록의 첫 지역으로 전환한다. +- 2026-06-25: 지역 select는 밝은 청록 계열로 유지하고, 오른쪽 필터 chip/search/sort는 따뜻한 주황 계열로 분리한다. 선택된 chip은 주황 그라데이션 배경으로 눌림 상태를 더 강하게 표시한다. +- 2026-06-25: 실거래 단지 지오코딩과 검색 필터는 `시군구+동+공식명` 단일 문자열에만 의존하지 않는다. 지번이 있으면 `시군구+동+지번`을 먼저 시도하고, `블록 -> 단지` 별칭 후보와 지역 거리 검증을 함께 적용한다. 카카오가 너무 넓은 별칭을 다른 지역으로 매칭하면 기본 근사좌표 상태를 유지한다. +- 2026-06-25: 실거래 지도 기준월을 `202606`으로 맞추고 비교월을 `202605`(MoM), `202512`(6개월), `202506`(YoY)로 전환한다. 비교월 데이터가 없으면 마커는 중립색으로 남는다. +- 2026-06-25: 상세 패널 하단의 커뮤니티 반응, 관련 뉴스/콘텐츠, 정책 타임라인 블록과 해당 target lookup/fetch 흐름을 제거한다. 상세 패널은 거래 요약, 가격 흐름, 주변 시설, provider 상태만 유지한다. +- 2026-06-25: 실거래 화면 기본 필터를 `아파트`, `전체`, `MoM`으로 두고, 가격 비교 기간 버튼은 `MoM`, `6개월`, `YoY` 순서로 배치한다. 정렬 select는 매물 유형 chip 묶음 왼쪽에 배치한다. +- 2026-06-25: 실거래 marker/목록 row 선택 시 오른쪽 `최근 본` 탭에 부동산을 기록하고, `region/property/deal/selected` query로 같은 상세 패널을 복구하는 기준을 추가. +- 2026-06-25: 대시보드 검색의 route `q` 진입에서 첫 매칭 단지를 자동 선택하고, 상세 패널이 열릴 때 지도 중심을 선택 단지 좌표로 맞추는 기준을 추가. +- 2026-06-25: 상단 가격 비교 기간과 상세 패널 비교 기간을 분리한다. 상세 패널은 열릴 때만 현재 상단 기간을 기본값으로 받고, 이후 상세 내부 변경은 선택 단지 가격 흐름에만 적용한다. +- 2026-06-25: 상세 패널의 단지명 바로 오른쪽 하트로 로그인 사용자 기반 `complex` 관심 저장/해제를 지원하고, 오른쪽 `관심` 탭에서 저장된 실거래 상세 경로로 복귀하는 기준 추가. +- 2026-06-25: 상세 패널 하트 옆 말풍선 버튼으로 선택 단지를 채팅 입력창에 첨부한다. 첨부에는 단지명, 위치/가격, 상세 비교 기간 등락률 또는 `비교데이터 없음`, `region/property/deal/selected` 복귀 URL을 포함한다. diff --git a/docs/layers/ui/screens/watchlist.md b/docs/layers/ui/screens/watchlist.md deleted file mode 100644 index d25f538..0000000 --- a/docs/layers/ui/screens/watchlist.md +++ /dev/null @@ -1,70 +0,0 @@ -# 마이페이지 화면 - -## Route - -- Parent: root -- Route 정보: `/realestate/mypage` -- Legacy route: `/realestate/watchlist`는 북마크 호환을 위해 `/realestate/mypage`로 redirect -- Child screens: 저장 대상 추가 drawer, 알림 조건 관리 drawer, 개인 메모 editor 후보 - -## 화면 목적 - -사용자가 저장한 지역을 관리하고, 지난 방문 이후 바뀐 시장 사실을 확인하는 개인화 공간입니다. - -지역 반응 TOP10이나 임시 관심 후보를 저장 목록처럼 보여주는 화면이 아닙니다. 로그인/사용자 저장 API가 열리기 전에는 실제 저장 지역·단지처럼 보이는 mock 목록을 두지 않고, `로그인 후 확인`, `데이터 확인 전`, `준비 중` 상태를 분리해서 보여줍니다. - -## 현재 섹션 - -- 내 부동산 관찰 보드 hero - - 사용자 저장 기반 개인 공간임을 설명 - - 로그인 연동 전 준비 상태 표시 -- 마이페이지 상태 요약 - - 저장 대상, 최근 변화, 알림 조건, 개인 메모 -- 내 저장 지역·단지 - - 저장된 대상이 없을 때 빈 상태 표시 - - 태그 후보: 실거주, 전세, 청약, 투자관찰, 교통, 재건축, 공급 -- 지난 방문 이후 바뀐 것 - - 새 실거래·전세 거래 - - 주요 일정 도래 - - 정책·공급·청약 이슈 - - 근거 브리핑 갱신 - - 공식 데이터 없음 -- 내 알림 조건 - - 거래 변화, 전세 압력, 주요 일정, 정책·공급 이벤트 -- 지역별 관찰 메모 - - 전세 위주로 보기, 청약 일정 체크, 실거주 후보 같은 개인 메모 예시 -- 저장 지역 비교 - - 실거래, 전세, 공급, 일정, 근거 기준 비교 준비 상태 - -## 상태와 빈 화면 - -- loading: 저장 목록 API가 붙으면 skeleton을 보여줍니다. -- empty: 저장된 지역·단지가 없다는 빈 상태와 저장 태그 후보를 보여줍니다. -- error: 저장 목록, 알림 조건, 개인 메모 실패를 분리합니다. -- stale/mock: 사용자 저장 목록에는 mock row를 넣지 않습니다. 시장 fact 자체가 지연되면 해당 대상의 `공개 지연`, `데이터 확인 전` 상태로만 표시합니다. - -## API 후보 - -| 필드 | 소유 도메인/layer | 설명 | -| --- | --- | --- | -| `watchSummary` | user/backend | 로그인 후 저장 대상 수, 최근 변화 수, 알림 조건 수, 메모 수 | -| `watchTargets` | user/realestate | 사용자가 저장한 지역/단지 목록과 내부 target id | -| `watchTargetChanges` | realestate/user | 지난 방문 이후 바뀐 실거래, 전세, 일정, 정책·공급 이슈 | -| `alertRules` | user/realestate | 거래 변화, 전세 압력, 일정, 정책·공급 이벤트 알림 조건 | -| `observationNotes` | user/realestate | 사용자가 직접 남긴 지역별 관찰 메모 | -| `watchComparison` | realestate/user | 저장 지역 비교용 실거래, 전세, 공급, 일정, 근거 상태 | - -## 기획 확인 필요 - -- 로그인/사용자 저장 API를 여는 시점 -- 저장 가능한 대상 범위: 지역, 동, 단지, 생활권 -- 알림 조건 threshold와 notification channel -- 개인 메모를 서버 저장으로 둘지 로컬 우선으로 둘지 - -## 변경 로그 - -- 2026-06-23: 기존 watchlist 화면을 로그인 기반 `마이페이지`로 전환. `/realestate/mypage`를 정본 route로 두고 `/realestate/watchlist`는 redirect로 유지. -- 2026-06-22: 관심 후보 기준을 reaction ranking에서 지도/실거래 탐색 기반 시장 후보로 전환. -- 2026-06-15: 로그인/저장 전 mock watchlist를 제거하고 region reaction ranking 기반 관심 후보와 빈 상태로 전환. -- 2026-06-12: 화면 정본 route를 `/realestate/watchlist`로 고정하고 레거시 호환 route를 제거. -- 2026-06-01: 기존 원장형 화면을 관심 지역 관리 화면으로 전환. diff --git a/docs/product/FINAL_PRODUCT_PLAN.md b/docs/product/FINAL_PRODUCT_PLAN.md index 7c49313..847170e 100644 --- a/docs/product/FINAL_PRODUCT_PLAN.md +++ b/docs/product/FINAL_PRODUCT_PLAN.md @@ -28,7 +28,7 @@ 1. 사용자는 대시보드에서 오늘 확인할 지역 흐름과 핵심 뉴스/리포트를 확인합니다. 2. 지도에서 전국과 시군구 흐름을 공간적으로 확인합니다. 3. 실거래 탐색에서 지역, 기간, 거래유형, 가격대, 면적 조건으로 공식 거래 데이터를 좁혀 봅니다. -4. 주요 일정에서 가격지수, 실거래 공개, 미분양·공급, 금리, 청약, 정책 발표 시점을 확인합니다. +4. 주요 일정에서 가격지수·거래현황, 미분양·공급, 금리, 청약, 정책·공시가격 발표 시점을 확인하고, 실거래 공개는 freshness/status로 확인합니다. 5. 지역 상세에서 실거래/전세/매물 흐름과 정책·뉴스 근거를 하나의 타임라인에서 봅니다. 6. 에이전트 평가와 근거 로그로 어떤 데이터가 판단에 쓰였는지 확인합니다. @@ -67,7 +67,7 @@ SerpApi 같은 검색 API는 관심도 점수의 원천이 아닙니다. 검색 | 지도 흐름 | 가격지수/market fact -> 지역 target 연결 -> map layer snapshot | 전국/시군구 흐름 지도 | | 실거래 탐색 | 공공데이터/API -> 거래 row 정규화 -> 필터/정렬 API | 매매·전월세 탐색 테이블 | | 시장 사실 | 공공데이터/API -> 시점별 저장 -> 이벤트 타임라인 -> 변화 감지 | 가격/전세/매물 흐름, 뉴스/정책 연결 | -| 주요 일정 | 공식 출처/반복 일정 -> 일정 캘린더 -> source link | 가격지수, 실거래, 공급, 금리, 청약 일정 | +| 주요 일정 | 공식 출처/반복 일정 -> 일정 캘린더 -> source link | 가격지수·거래현황, 공급, 금리, 청약, 정책·공시가격 일정 | | 보조 반응 | 공개 source 후보 -> 제한 저장 -> 쟁점 후보 보강 | 보조 관찰 키워드 | | 에이전트 판단 | 지표 입력 -> AI 근거 검색 -> 맥락 종합 -> 근거 로그 | 지역/단지 평가와 caveat | @@ -109,7 +109,7 @@ SerpApi 같은 검색 API는 관심도 점수의 원천이 아닙니다. 검색 ### 4. 주요 일정 -주요 일정은 가격지수, 실거래 공개, 미분양·공급, 금리, 청약, 정책 발표를 캘린더로 보여주는 화면입니다. 사용자는 언제 어떤 공식 데이터를 다시 확인해야 하는지 판단합니다. +주요 일정은 가격지수·거래현황, 미분양·공급, 금리, 청약, 정책·공시가격 발표를 캘린더로 보여주는 화면입니다. 실거래 공개는 고정 일정이 아니라 freshness/status로 분리해 사용자가 언제 어떤 공식 데이터를 다시 확인해야 하는지 판단합니다. 필수 표시 기준: diff --git a/docs/product/PROJECT_BRIEF.md b/docs/product/PROJECT_BRIEF.md index fa33631..514a7b1 100644 --- a/docs/product/PROJECT_BRIEF.md +++ b/docs/product/PROJECT_BRIEF.md @@ -22,7 +22,7 @@ 2. 지도에서 전국과 시군구 단위 흐름을 확인합니다. 3. 실거래 탐색에서 지역, 기간, 가격, 면적, 거래유형 조건으로 공식 거래 데이터를 필터링합니다. 4. 뉴스/컬럼, 정책/개발/교통 이벤트, 실거래/전세/매물 흐름을 같은 시간축에서 봅니다. -5. 주요 일정에서 가격지수, 실거래, 미분양, 금리, 청약, 정책 발표 시점을 확인합니다. +5. 주요 일정에서 가격지수·거래현황, 미분양·공급, 금리, 청약, 정책·공시가격 발표 시점을 확인하고, 실거래 공개는 freshness/status로 확인합니다. 6. 에이전트 평가와 근거 로그로 어떤 데이터가 판단에 쓰였는지 확인합니다. ## 우선 완성할 세로 Slice @@ -32,7 +32,7 @@ - 지역 상세: market fact timeline, 뉴스/컬럼 링크, 정책/공급 이벤트, 근거 로그를 연결합니다. - 지역/단지 matcher: 공공데이터의 법정동/단지명과 서비스 target을 우선 연결하고, 공개 원문 별칭은 보조 confidence로 남깁니다. - target graph: 지역, 생활권, 정책 영향권, 단지의 roll-up/drill-down 관계를 관리합니다. -- 주요 일정: 공식 통계, 실거래 공개, 미분양·공급, 금리, 청약, 정책 발표 일정을 관리합니다. +- 주요 일정: 공식 통계, 거래현황, 미분양·공급, 금리, 청약, 정책·공시가격 발표 일정을 관리하고, 실거래 공개는 freshness/status로 분리합니다. - 에이전트 근거 로그: 지역 평가, 단지 보조 근거, caveat, provider/asOf/stale 상태를 기록합니다. ## 데이터 전략 @@ -58,4 +58,4 @@ - 정책/개발/교통 이벤트 provider 확장 - 유사 과거 상황 검색용 임베딩/벡터 저장소 - 질문형 분석 -- 마이페이지 저장 지역/단지 알림 +- 사용자 저장 지역/단지 알림은 별도 화면 없이 후순위 확장으로 검토 diff --git a/docs/product/REAL_ESTATE_PRODUCT_DIRECTION.md b/docs/product/REAL_ESTATE_PRODUCT_DIRECTION.md index 185d89d..33a7493 100644 --- a/docs/product/REAL_ESTATE_PRODUCT_DIRECTION.md +++ b/docs/product/REAL_ESTATE_PRODUCT_DIRECTION.md @@ -16,7 +16,7 @@ - 지도에서 전국과 시군구 단위 가격 흐름과 데이터 공백을 확인하게 합니다. - 실거래 탐색에서 지역, 기간, 거래유형, 가격, 면적 조건으로 공식 거래 데이터를 필터링하게 합니다. - 뉴스, 리포트, 영상, 공식 일정에서 지역 이슈 근거 후보를 찾습니다. -- 가격지수, 실거래 공개, 미분양·공급, 금리, 청약, 정책 발표 일정을 캘린더로 관리합니다. +- 가격지수·거래현황, 미분양·공급, 금리, 청약, 정책·공시가격 발표 일정을 캘린더로 관리하고, 실거래 공개는 freshness/status로 분리합니다. - 공개 커뮤니티 반응은 필요한 경우에만 보조 쟁점 후보로 쓰고 핵심 순위나 점수로 만들지 않습니다. - 에이전트는 결론만 말하지 않고 어떤 근거를 봤는지 로그로 남깁니다. @@ -54,7 +54,7 @@ - 대상 정본은 `region`, `complex`, `living_area`, `policy_area`, `real_estate_target`입니다. - 시장 사실은 `transaction`, `rent`, `listing`, `policy event`, `market fact`로 나눕니다. -- 사용자 화면은 거래 판단이 아니라 관찰형 분석, 실거래 탐색, 마이페이지 저장 지역 관리, 근거 로그 확인에 집중합니다. +- 사용자 화면은 거래 판단이 아니라 관찰형 분석, 실거래 탐색, 주요 일정 확인, 근거 로그 확인에 집중합니다. - freshness는 실거래/매물/전세/정책 데이터별 `provider`, `asOf`, `stale`, `ingestedAt` 기준으로 분리합니다. ## 주요 기능 후보 @@ -65,7 +65,7 @@ | 실거래 탐색 | 지역, 기간, 거래유형, 가격대, 면적 조건으로 공식 거래 데이터 확인 | 특정 거래를 추천하지 않음 | | 실시간 이슈/뉴스/컬럼 | 정책, 교통, 공급, 전세, 개발 이슈를 한 흐름으로 확인 | 원문 전문 재게시 금지 | | 시장 사실 타임라인 | 실거래, 전세, 매물, 정책 이벤트를 시간순으로 확인 | provider, `asOf`, `stale` 분리 | -| 주요 일정 | 가격지수, 실거래 공개, 공급, 금리, 청약, 정책 발표 확인 | 발표 지연과 기준 시각 표시 | +| 주요 일정 | 가격지수·거래현황, 공급, 금리, 청약, 정책·공시가격 발표 확인 | 발표 지연과 기준 시각 표시 | | 에이전트 근거 로그 | 평가가 어떤 근거를 봤는지 추적 | 내부 추론 전문 노출 금지 | ## 1차 데이터 계약 초안 diff --git a/docs/product/REGIONAL_ISSUE_BRIEFING_MVP.md b/docs/product/REGIONAL_ISSUE_BRIEFING_MVP.md index efbb4ea..b25d855 100644 --- a/docs/product/REGIONAL_ISSUE_BRIEFING_MVP.md +++ b/docs/product/REGIONAL_ISSUE_BRIEFING_MVP.md @@ -30,7 +30,7 @@ ## 데이터 우선순위 1. 공공데이터와 공식 지표 - - 지역별 가격지수, 실거래/전월세, 미분양, 공급, 인허가, 정책 이벤트 + - 지역별 가격지수, 실거래/전월세 freshness, 미분양, 공급, 인허가, 정책 이벤트 - provider, asOf, stale, 데이터 없음 상태를 분리한다. 2. 최근 이슈 후보 - SerpApi, 뉴스, 리포트, 영상, 블로그를 통해 정책, 교통, 공급, 전세, 금리, 개발 이슈 후보를 수집한다. @@ -87,7 +87,7 @@ 4. 일일 AI 지역 브리핑 - 공공데이터, 최근 이슈 후보, 커뮤니티 보조 신호를 입력으로 EvidenceLog를 생성한다. 5. 주요 일정 화면 - - 가격지수, 실거래, 미분양·공급, 금리, 청약, 정책 발표 일정을 캘린더로 정리한다. + - 가격지수·거래현황, 미분양·공급, 금리, 청약, 정책·공시가격 발표 일정을 캘린더로 정리하고 실거래 공개는 freshness/status로 분리한다. 6. 단지/아파트 상세 보강 - 공식 실거래, 좌표, alias가 연결된 단지만 상세 품질을 올린다. diff --git a/docs/product/SSAFY_USER_WORKFLOW.html b/docs/product/SSAFY_USER_WORKFLOW.html new file mode 100644 index 0000000..b0219ba --- /dev/null +++ b/docs/product/SSAFY_USER_WORKFLOW.html @@ -0,0 +1,1079 @@ + + + + + + 너나사 부동산 사용자 워크플로우 + + + +
    +
    +
    너나사 YouBuyFirst RealEstate
    +
    + SSAFY 관통프로젝트 + 사용자 워크플로우 + 2026.06.25 +
    +
    + +
    +
    +

    사용자 흐름

    +

    실거래, 일정, 근거를 따라 지역 인사이트까지 확인하는 사용자 흐름

    +

    너나사 부동산은 매수나 청약 행동을 권유하는 서비스가 아니라, 사용자가 실거래·전세·공급·정책 흐름을 차례로 확인하면서 관심 지역의 시장 상황을 이해하도록 돕는 관찰형 부동산 인사이트 서비스입니다.

    +
    + +
    + +
    +
    + 가치 1 + 첫 화면에서 오늘 볼 시장 흐름 파악 +

    대시보드는 오늘의 핵심 변화, 주간 가격 흐름, 관련 뉴스·리포트를 먼저 보여줘 사용자가 바로 탐색을 시작하게 합니다.

    +
    +
    + 가치 2 + 지도와 실거래로 사실 검증 +

    지도는 지역 흐름을 한눈에 보여주고, 실거래 화면은 조건별 거래 목록과 공개 지연 상태를 함께 보여줍니다.

    +
    +
    + 가치 3 + 근거와 상태를 숨기지 않는 리포트 +

    상세 화면은 기준일, 출처, 최신 여부, 근거 링크를 함께 보여줘 사용자가 정보의 한계를 확인할 수 있게 합니다.

    +
    +
    + +
    +
    +

    전체 사용자 여정

    +
    +
    +
    + 01 + 첫 화면에서 흐름 잡기 + 오늘의 3줄 브리핑에서 최근 시장 변화, 주목 지역, 관련 이슈를 빠르게 훑습니다. +
    +
    + 02 + 관심 지역 찾기 + 궁금한 지역명, 동 이름, 단지명을 검색해 바로 확인할 대상을 고릅니다. +
    +
    + 03 + 지도에서 비교하기 + 전국과 시군구 지도를 보며 어느 지역이 오르고 내렸는지 색상과 수치로 비교합니다. +
    +
    + 04 + 실거래로 확인하기 + 지역, 거래 방식, 비교 기간을 바꿔 실제 거래와 전월세 목록을 확인합니다. +
    +
    + 05 + 일정과 원문 보기 + 주요 일정과 뉴스룸에서 통계 발표, 청약, 공급, 정책, 리포트 원문을 확인합니다. +
    +
    + 06 + 다시 볼 대상 남기기 + 관심 목록, 최근 본 항목, 채팅 공유로 나중에 다시 이어볼 대상을 남깁니다. +
    +
    +
    + +
    +
    +

    핵심 사용자와 성공 기준

    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    사용자 유형진입 이유성공 기준
    지역 흐름을 빠르게 보고 싶은 사용자오늘 어느 지역의 실거래·전세·공급·정책 흐름을 먼저 볼지 정해야 함대시보드와 지도에서 확인할 지역을 고르고 상세 리포트까지 확인
    실제 거래 데이터를 직접 보고 싶은 사용자뉴스나 지도에서 본 흐름이 실제 거래와도 맞는지 확인하고 싶음실거래 화면에서 조건을 좁히고 거래일, 면적, 가격, 기준일 확인
    공식 일정과 근거를 챙기는 사용자통계, 청약, 공급, 금리, 정책 발표를 놓치지 않고 싶음주요 일정에서 날짜와 공식 출처를 확인하고 뉴스룸에서 원문 링크 확인
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    화면사용자가 보는 정보다음 행동
    대시보드오늘의 3줄 브리핑, 주간 가격 흐름, 뉴스·리포트 요약관심 지역 검색 또는 지도·실거래 화면으로 이동
    지역 분석 지도전국, 시도, 시군구 단위 가격·거래 흐름지역을 눌러 상세 리포트와 근거 확인
    실거래조건별 거래 목록, 비교 기간, 공개 지연 상태거래 상세를 열어 가격 흐름과 위치 확인
    주요 일정공식 통계, 청약, 공급, 정책, 금리 발표일공식 출처로 이동해 발표 내용 확인
    뉴스룸뉴스, 리포트, 영상, 블로그·커뮤니티 원문 링크대시보드와 상세 리포트에서 본 이슈의 원문 확인
    지역/단지 상세시장 흐름, 핵심 쟁점, 시간대별 변화, 근거 링크관심 저장, 최근 본 복귀, 채팅 공유
    +
    +
    + +
    + +
    +
    +
    너나사 사용자 시나리오
    +
    + 주요 흐름 1 + 탐색과 검증 +
    +
    + +
    +
    +

    워크플로우 1. 첫 접속 후 관심 지역 흐름 확인

    +
    +
    + +
      +
    1. 1대시보드에서 오늘의 3줄 브리핑을 읽습니다. 브리핑은 최근 시장 변화, 주목 지역, 관련 이슈를 짧은 문장으로 보여줍니다.
    2. +
    3. 2오른쪽 주간 가격 흐름 영역에서 이번 주 많이 오른 지역과 많이 내린 지역을 확인합니다.
    4. +
    5. 3검색창에 지역명이나 동 이름을 입력해 관심 지역을 찾고, 검색 결과를 눌러 지역 분석 지도로 이동합니다.
    6. +
    7. 4지도에서 기간 탭을 바꾸며 상승, 하락, 자료 없음 상태를 비교합니다.
    8. +
    9. 5지역 리포트 패널에서 기대 포인트, 우려 포인트, 기준일, 근거 출처를 확인합니다.
    10. +
    + +
    +
    + +
      +
    1. A지도 색상은 공개 반응이나 분위기가 아니라 공식 가격지수 또는 확인된 시장 데이터를 기준으로 합니다.
    2. +
    3. B지역을 누르면 해당 지역의 요약 리포트가 열리고, 기대 요인과 우려 요인이 나뉘어 보입니다.
    4. +
    5. C근거 출처 영역에서는 어떤 공식 자료나 데이터가 리포트에 쓰였는지 확인할 수 있습니다.
    6. +
    7. D자료가 없거나 아직 공개되지 않은 지역은 임의 수치 대신 자료 없음, 공식 지수 미공표, 확인 필요로 표시합니다.
    8. +
    + +
    +
    + +
    +
    +

    워크플로우 2. 실거래 화면에서 숫자로 검증

    +
    +
    + +
      +
    1. 1상단 내비게이션의 실거래 또는 검색 결과의 법정동·단지 후보를 선택합니다.
    2. +
    3. 2시도, 시군구, 매물 유형, 거래 방식, 비교 기간을 바꿔 조건에 맞는 거래만 좁혀 봅니다.
    4. +
    5. 3지도 표시나 왼쪽 거래 목록을 눌러 선택한 거래의 요약과 가격 흐름을 봅니다.
    6. +
    7. 4상세 패널에서 거래일, 면적, 가격, 주변 시설, 기준일, 공개 지연 상태를 확인합니다.
    8. +
    9. 5로그인한 사용자는 하트 버튼으로 단지를 관심 목록에 저장하고, 말풍선 버튼으로 채팅에 첨부합니다.
    10. +
    + +
    +
    + +
      +
    1. A조건에 맞는 거래가 없으면 공식 거래 없음과 공개 지연 가능성을 표시합니다.
    2. +
    3. B비교할 이전 데이터가 없으면 가격 변화율을 억지로 계산하지 않고 비교 데이터 없음으로 둡니다.
    4. +
    5. C좌표가 불확실한 단지는 좌표 검증 전 상태로 표시하고 확정 위치처럼 말하지 않습니다.
    6. +
    7. D상세 패널은 커뮤니티 반응보다 거래 요약, 가격 흐름, 데이터 기준일에 집중합니다.
    8. +
    + +
    +
    + +
    +
    +

    워크플로우 3. 일정과 뉴스룸에서 근거 링크 확인

    +
    +
    + +
      +
    1. 1주요 일정 화면에서 월간 캘린더를 열고 가격·공시, 거래현황, 공급, 금융, 정책, 청약 일정을 봅니다.
    2. +
    3. 2날짜 칸의 일정 줄을 눌러 하단 요약 행으로 이동합니다.
    4. +
    5. 3공식 출처 링크가 있는 일정은 요약 행에서 실제 공표 상세, 청약 모집공고, 통화정책 일정으로 이동합니다.
    6. +
    7. 4뉴스룸에서 뉴스, 리포트, 영상, 블로그·커뮤니티 링크를 종류별로 확인합니다.
    8. +
    9. 5상세 리포트의 근거 출처와 뉴스룸 원문을 교차 확인합니다.
    10. +
    + +
    +
    + +
    + +
    +
    +
    너나사 계정 기반 재방문 흐름
    +
    + 주요 흐름 2 + 관심 · 최근 본 · 채팅 +
    +
    + +
    +
    +

    워크플로우 4. 로그인, 관심 저장, 최근 본 복귀

    +
    +
    + +
      +
    1. 1로그인 화면에서 아이디와 비밀번호를 입력하거나 사용할 수 있는 간편 로그인을 선택합니다.
    2. +
    3. 2로그인에 성공하면 대시보드로 돌아오고 상단에 사용자 닉네임과 로그아웃 버튼이 표시됩니다.
    4. +
    5. 3지역 분석 리포트나 실거래 상세에서 하트 버튼으로 관심 지역·단지를 저장합니다.
    6. +
    7. 4오른쪽 패널의 관심 탭에서 저장된 대상을 불러오고, 항목을 누르면 이전에 보던 화면으로 돌아갑니다.
    8. +
    9. 5최근 본 탭은 로그인과 무관하게 같은 브라우저에서 본 지역·실거래를 최대 15개까지 보관합니다.
    10. +
    + +
    +
    + +
      +
    1. A관심 목록은 로그인한 사용자에게만 보여줍니다.
    2. +
    3. B비로그인 사용자가 관심 목록을 열면 저장 목록 대신 로그인 안내를 보여줍니다.
    4. +
    5. C최근 본 항목은 같은 브라우저에서 빠르게 돌아가기 위한 임시 목록으로 사용합니다.
    6. +
    7. D사용할 수 없는 간편 로그인 버튼은 비활성 상태로 보여줘 잘못된 화면으로 이동하지 않게 합니다.
    8. +
    + +
    +
    + +
    +
    +

    워크플로우 5. 채팅에서 지역·실거래 맥락 공유

    +
    +
    + +
      +
    1. 1사이트 첫 진입 시 오른쪽의 채팅 패널이 기본으로 열립니다.
    2. +
    3. 2로그인 사용자는 닉네임으로 바로 참여하고, 비로그인 사용자는 게스트 닉네임을 입력해 참여합니다.
    4. +
    5. 3지역 분석 리포트 또는 실거래 상세의 말풍선 버튼을 누르면 현재 대상이 채팅 입력창에 첨부됩니다.
    6. +
    7. 4첨부 카드는 종류, 제목, 필요한 설명, 기간과 등락률을 압축해서 보여줍니다.
    8. +
    9. 5전송된 첨부 카드를 클릭하면 원래 보던 지역 분석 또는 실거래 상세 화면으로 돌아갑니다.
    10. +
    + +
    +
    + +
      +
    1. A최근 메시지는 새로고침해도 유지되도록 서버에 저장됩니다.
    2. +
    3. B다른 사용자가 새 메시지를 보내면 채팅창에 바로 나타납니다.
    4. +
    5. C참여자 수는 단순 방문자 수가 아니라 채팅 패널을 열어 둔 사람 수로 보여줍니다.
    6. +
    7. D게스트 닉네임은 기존 가입자 닉네임과 겹치지 않게 확인한 뒤 사용할 수 있습니다.
    8. +
    + +
    +
    + +
    +
    +

    사용자에게 보여주는 상태와 예외

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    상태의미사용자 화면 처리사용자가 할 수 있는 일
    불러오는 중정보를 불러오는 중빈 화면 대신 자리 표시와 확인 중 문구를 보여주고 중복 입력을 막음잠시 기다리거나 다른 화면 탐색
    자료 없음조건에 맞는 공식 거래나 일정이 없음공식 거래 없음, 일정 수집 전, 관련 화면 링크기간·지역 조건 변경, 공식 출처 확인
    갱신 지연공개 지연 또는 마지막 수집 기준 초과기준일과 마지막 확인 시각을 함께 표시주요 일정에서 다음 공표일 확인
    확인 필요아직 확정하지 못한 값확인 필요, 자료 없음, 미공표로 구분근거 링크 또는 공식 출처로 교차 확인
    예시 데이터검증 전이거나 화면 예시를 위한 데이터실제 데이터처럼 섞지 않고 예시임을 표시실서비스 판단에는 사용하지 않음
    +
    + +
    + +
    +
    +
    너나사 사용 흐름 요약
    +
    + 서비스 경계 + 경계와 검증 +
    +
    + +
    +
    +

    대표 사용 흐름 요약

    +
    +
    +
    + 대시보드 진입 + 오늘의 3줄 브리핑과 주간 가격 흐름을 보며 오늘 확인할 지역 후보를 고릅니다. +
    +
    + 지역 검색 + 검색 자동완성으로 지역·법정동·단지 후보를 찾고 지도 또는 실거래 화면으로 이동합니다. +
    +
    + 지도 리포트 + 기간 탭과 시군구 drill-down으로 상승·하락 흐름과 근거 출처를 확인합니다. +
    +
    + 실거래 검증 + 매물 유형, 거래 방식, 비교 기간을 바꾸며 실제 거래 항목과 공개 지연을 확인합니다. +
    +
    + 저장과 공유 + 관심 저장, 최근 본 복귀, 채팅 첨부 카드로 확인한 대상을 다시 이어봅니다. +
    +
    +
    + +
    +
    +

    서비스 경계와 문구 원칙

    +
    +
    +
    + 행동 권유 금지 +

    매수, 매도, 청약, 대출을 권유하지 않습니다. 상승 확정, 수익 보장, 진입 신호 같은 표현도 쓰지 않습니다.

    +
    +
    + 공식 데이터 우선 +

    실거래, 전세, 공급, 정책, 가격지수는 출처, 기준일, 최신 여부를 분리해 보여줍니다.

    +
    +
    + 커뮤니티는 보조 관찰 +

    공개 반응은 이슈 해석 보조 신호일 뿐 핵심 순위나 확정 판단의 기준으로 쓰지 않습니다.

    +
    +
    + 원문 저장 제한 +

    뉴스·리포트·공개 원문은 제목, 짧은 요약, 원문 링크, 출처, 시각 중심으로 제한 저장합니다.

    +
    +
    + 불확실성 표시 +

    확인할 수 없는 값은 확인 필요, 수집 전, 예시 데이터처럼 구분하고 임의로 채우지 않습니다.

    +
    +
    + 근거 로그 노출 +

    AI가 어떤 지표와 링크를 참고했는지 사용자용 요약과 주의 사항으로 남깁니다.

    +
    +
    +
    + +
    +
    +

    화면별로 사용자가 확인하는 정보

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    사용자 화면주요 확인 정보사용자가 얻는 답주의해서 보는 부분
    대시보드오늘의 3줄 브리핑, 주간 가격 흐름, 뉴스·리포트 요약오늘 어느 지역과 이슈를 먼저 볼지 정합니다.요약은 출발점이며, 상세 화면에서 근거를 다시 확인합니다.
    지역 분석 지도지도 색상, 상승·하락 수치, 지역 리포트, 근거 출처관심 지역이 주변 지역과 비교해 어떤 흐름인지 봅니다.자료 없음, 공식 지수 미공표, 기준일을 함께 확인합니다.
    실거래거래 목록, 거래일, 면적, 가격, 비교 기간, 공개 지연 상태지도에서 본 흐름을 실제 거래 기준으로 확인합니다.거래가 없다는 말이 시장에 거래가 전혀 없다는 뜻은 아닐 수 있습니다.
    주요 일정통계 발표일, 청약 일정, 공급·정책·금리 발표, 공식 출처언제 어떤 데이터를 다시 확인해야 하는지 알 수 있습니다.날짜가 확인된 공식 일정만 실제 일정처럼 표시합니다.
    뉴스룸뉴스, 정책·통계 리포트, 영상, 블로그·커뮤니티 링크관심 지역과 연결된 이슈의 원문을 더 깊게 확인합니다.원문 전문을 복제하지 않고 출처와 링크 중심으로 확인합니다.
    관심·채팅저장한 지역·단지, 최근 본 항목, 채팅 첨부 카드봤던 정보를 다시 열고 다른 사용자와 공유합니다.관심 목록은 로그인 사용자에게만 저장됩니다.
    +
    + +
    +
    +

    사용자 워크플로우 완료 정의

    +
    +
    +
    + 사용자는 첫 화면에서 다음 행동을 정한다 +

    대시보드가 소개 페이지가 아니라 검색, 브리핑, 지도, 실거래, 일정으로 이어지는 실제 시작점이어야 합니다.

    +
    +
    + 모든 흐름은 근거 화면으로 닫힌다 +

    지도, 실거래, 일정, 뉴스룸, 상세 리포트가 서로 연결되어 출처와 기준 시각을 확인할 수 있어야 합니다.

    +
    +
    + 다시 보기와 공유가 가능하다 +

    관심, 최근 본, 채팅 첨부 카드가 사용자의 재방문과 팀원 간 설명 흐름을 지원해야 합니다.

    +
    +
    +
    + +
    + + diff --git a/docs/product/SSAFY_USER_WORKFLOW.pdf b/docs/product/SSAFY_USER_WORKFLOW.pdf new file mode 100644 index 0000000..2be2896 Binary files /dev/null and b/docs/product/SSAFY_USER_WORKFLOW.pdf differ diff --git a/front/src/App.vue b/front/src/App.vue index 84ff70a..b76660c 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -1,5 +1,5 @@