feat: 인앱 결제(Google Play) 스캐폴드 + 멤버십 만료 처리
CI / build (push) Failing after 13m46s

- member.plan_expires_at + iap_purchase 테이블(중복 결제 방지)
- POST /api/billing/google/verify: 구매 검증 후 PREMIUM 부여/갱신(만료 연장), 세션 plan 즉시 동기화
- GET /api/billing/products: 구독 상품(월간/연간)
- 테스트 모드(billing.test-mode=true): test- 토큰 검증 없이 승인 → 전 과정 테스트 가능
- GooglePlayVerifier 인터페이스 + 스텁(실연동 지점), 실연동 시 구현체 교체
- 관리자 수동 plan 변경은 만료일 없이(영구), 결제는 만료일 부여
- 만료 자동 강등 스케줄러(매시), MemberResponse.planExpiresAt 노출

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-28 15:12:45 +09:00
parent d1c13e7cc1
commit 1e93280140
21 changed files with 421 additions and 4 deletions
@@ -7,7 +7,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling @EnableScheduling
@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper", "com.sb.web.point.mapper"}) @MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper", "com.sb.web.point.mapper", "com.sb.web.billing.mapper"})
public class SbBtApplication { public class SbBtApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -30,6 +30,7 @@ public class Member implements Serializable {
private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜 private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜
private String role; // USER / ADMIN private String role; // USER / ADMIN
private String plan; // FREE / PREMIUM (멤버십) private String plan; // FREE / PREMIUM (멤버십)
private LocalDateTime planExpiresAt; // 유료 멤버십 만료일 (NULL=만료없음/무료)
private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상) private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상)
private String status; // ACTIVE / SUSPENDED / WITHDRAWN private String status; // ACTIVE / SUSPENDED / WITHDRAWN
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -18,6 +18,7 @@ public class MemberResponse {
private String provider; private String provider;
private String role; private String role;
private String plan; // FREE / PREMIUM private String plan; // FREE / PREMIUM
private java.time.LocalDateTime planExpiresAt; // 유료 멤버십 만료일
private Long points; // 활동 포인트 private Long points; // 활동 포인트
private String googlePicture; // 구글 아바타 URL private String googlePicture; // 구글 아바타 URL
private String profileImage; // 사용자 지정 프로필 이미지(data URL) private String profileImage; // 사용자 지정 프로필 이미지(data URL)
@@ -31,6 +32,7 @@ public class MemberResponse {
.provider(m.getProvider()) .provider(m.getProvider())
.role(m.getRole()) .role(m.getRole())
.plan(m.getPlan()) .plan(m.getPlan())
.planExpiresAt(m.getPlanExpiresAt())
.points(m.getPoints()) .points(m.getPoints())
.googlePicture(m.getGooglePicture()) .googlePicture(m.getGooglePicture())
.profileImage(m.getProfileImage()) .profileImage(m.getProfileImage())
@@ -21,6 +21,9 @@ public interface AuthSessionMapper {
/** 프로필 변경 시 백업 세션의 표시 이름 동기화 */ /** 프로필 변경 시 백업 세션의 표시 이름 동기화 */
int updateName(@Param("token") String token, @Param("name") String name); int updateName(@Param("token") String token, @Param("name") String name);
/** 멤버십 변경 시 백업 세션의 plan 동기화 */
int updatePlan(@Param("token") String token, @Param("plan") String plan);
int delete(@Param("token") String token); int delete(@Param("token") String token);
int deleteExpired(@Param("now") LocalDateTime now); int deleteExpired(@Param("now") LocalDateTime now);
@@ -50,6 +50,13 @@ public interface MemberMapper {
int updatePlan(@Param("id") Long id, @Param("plan") String plan); int updatePlan(@Param("id") Long id, @Param("plan") String plan);
/** 결제로 멤버십 부여/갱신 (plan + 만료일) */
int updateMembership(@Param("id") Long id, @Param("plan") String plan,
@Param("expiresAt") java.time.LocalDateTime expiresAt);
/** 만료된 유료회원을 FREE 로 강등 (스케줄러). 강등된 건수 반환 */
int downgradeExpired();
int updateStatus(@Param("id") Long id, @Param("status") String status); int updateStatus(@Param("id") Long id, @Param("status") String status);
int deleteById(@Param("id") Long id); int deleteById(@Param("id") Long id);
@@ -402,6 +402,32 @@ public class AuthService {
} }
} }
/** 결제/멤버십 변경 시 활성 세션(Redis + DB 백업)의 plan 을 즉시 맞춘다 (재로그인 없이 유료 기능 개방). */
public void syncSessionPlan(String token, String plan) {
if (token == null || token.isBlank()) {
return;
}
String key = SESSION_PREFIX + token;
try {
Object cached = redisTemplate.opsForValue().get(key);
if (cached instanceof SessionUser u) {
u.setPlan(plan);
Long ttl = redisTemplate.getExpire(key, java.util.concurrent.TimeUnit.SECONDS);
Duration remain = (ttl != null && ttl > 0)
? Duration.ofSeconds(ttl)
: (u.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
redisTemplate.opsForValue().set(key, u, remain);
}
} catch (Exception e) {
log.warn("[billing] Redis 세션 plan 동기화 실패(무시): {}", e.toString());
}
try {
authSessionMapper.updatePlan(token, plan);
} catch (Exception ignore) {
// DB 백업 동기화 실패해도 다음 로그인 시 갱신됨
}
}
/** /**
* 비밀번호 변경 (본인). 현재 비밀번호 검증 후 새 비밀번호로 교체. * 비밀번호 변경 (본인). 현재 비밀번호 검증 후 새 비밀번호로 교체.
*/ */
@@ -0,0 +1,39 @@
package com.sb.web.billing;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor;
import com.sb.web.billing.dto.MembershipResponse;
import com.sb.web.billing.dto.ProductResponse;
import com.sb.web.billing.dto.VerifyRequest;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 인앱 결제 API. (/api/billing/** — 로그인 필요)
* GET /products 구독 상품 목록
* POST /google/verify Google Play 구매 검증 → 멤버십 부여
*/
@RestController
@RequestMapping("/api/billing")
@RequiredArgsConstructor
public class BillingController {
private final BillingService billingService;
@GetMapping("/products")
public List<ProductResponse> products() {
return billingService.products();
}
@PostMapping("/google/verify")
public MembershipResponse verifyGoogle(
@Valid @RequestBody VerifyRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current,
HttpServletRequest request) {
return billingService.verifyGoogle(current.getId(), AuthInterceptor.resolveToken(request), req);
}
}
@@ -0,0 +1,111 @@
package com.sb.web.billing;
import com.sb.web.auth.domain.Member;
import com.sb.web.auth.mapper.MemberMapper;
import com.sb.web.auth.service.AuthService;
import com.sb.web.billing.domain.IapPurchase;
import com.sb.web.billing.dto.MembershipResponse;
import com.sb.web.billing.dto.ProductResponse;
import com.sb.web.billing.dto.VerifyRequest;
import com.sb.web.billing.mapper.IapPurchaseMapper;
import com.sb.web.common.exception.ApiException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 인앱 결제(Google Play) 처리 — 상품 목록, 구매 검증 후 멤버십 부여.
* 스캐폴드: billing.test-mode=true 면 'test-' 토큰을 실제 검증 없이 승인한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class BillingService {
private final MemberMapper memberMapper;
private final IapPurchaseMapper purchaseMapper;
private final GooglePlayVerifier googlePlayVerifier;
private final AuthService authService;
/** 실연동 전 기본 true — 'test-' 토큰으로 결제 흐름 테스트 가능 */
@Value("${billing.test-mode:true}")
private boolean testMode;
/** 구독 상품 카탈로그 (가격은 표시용 — 실제 금액은 Play Console 기준) */
private static final Map<String, ProductResponse> CATALOG = new LinkedHashMap<>();
static {
CATALOG.put("premium_monthly", new ProductResponse("premium_monthly", "프리미엄 월간", 1, 2900));
CATALOG.put("premium_yearly", new ProductResponse("premium_yearly", "프리미엄 연간", 12, 29000));
}
public List<ProductResponse> products() {
return List.copyOf(CATALOG.values());
}
/**
* Google Play 구매 검증 후 멤버십(PREMIUM) 부여/갱신.
* 같은 구매 토큰은 한 번만 처리(멱등). 세션 plan 도 즉시 동기화.
*/
@Transactional
public MembershipResponse verifyGoogle(Long memberId, String sessionToken, VerifyRequest req) {
ProductResponse product = CATALOG.get(req.getProductId());
if (product == null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "알 수 없는 상품입니다.");
}
String token = req.getPurchaseToken();
// 이미 처리된 결제면 현재 상태 반환 (중복 지급 방지)
IapPurchase existing = purchaseMapper.findByToken(token);
if (existing != null) {
return MembershipResponse.of(mustFind(memberId));
}
boolean test = testMode || token.startsWith("test-");
String platform;
if (test) {
platform = "TEST";
} else {
platform = "GOOGLE_PLAY";
if (!googlePlayVerifier.verify(req.getProductId(), token)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "결제 검증에 실패했습니다.");
}
}
// 기존 만료일이 미래면 그 시점부터 연장, 아니면 지금부터
Member member = mustFind(memberId);
LocalDateTime now = LocalDateTime.now();
LocalDateTime base = (member.getPlanExpiresAt() != null && member.getPlanExpiresAt().isAfter(now))
? member.getPlanExpiresAt() : now;
LocalDateTime expires = base.plusMonths(product.getMonths());
memberMapper.updateMembership(memberId, "PREMIUM", expires);
purchaseMapper.insert(IapPurchase.builder()
.memberId(memberId).platform(platform).productId(req.getProductId())
.purchaseToken(token).orderId(req.getOrderId())
.status("VERIFIED").expiresAt(expires).build());
authService.syncSessionPlan(sessionToken, "PREMIUM");
log.info("[billing] PREMIUM granted member={} product={} platform={} expires={}",
memberId, req.getProductId(), platform, expires);
member.setPlan("PREMIUM");
member.setPlanExpiresAt(expires);
return MembershipResponse.of(member);
}
private Member mustFind(Long memberId) {
Member m = memberMapper.findById(memberId);
if (m == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
}
return m;
}
}
@@ -0,0 +1,11 @@
package com.sb.web.billing;
/**
* Google Play 구매 토큰 검증 추상화.
* 실연동(androidpublisher API + 서비스 계정) 구현체로 교체하면 된다.
*/
public interface GooglePlayVerifier {
/** 구매 토큰이 유효하면 true. 검증 미구성/실패 시 예외 또는 false. */
boolean verify(String productId, String purchaseToken);
}
@@ -0,0 +1,27 @@
package com.sb.web.billing;
import com.sb.web.auth.mapper.MemberMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 만료된 유료 멤버십 자동 강등. 매시간 실행.
* (만료일이 NULL 인 관리자 영구 부여는 강등되지 않음)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PremiumExpiryScheduler {
private final MemberMapper memberMapper;
@Scheduled(cron = "0 5 * * * *") // 매시 5분
public void downgradeExpired() {
int n = memberMapper.downgradeExpired();
if (n > 0) {
log.info("[billing] 만료 멤버십 강등: {}건", n);
}
}
}
@@ -0,0 +1,19 @@
package com.sb.web.billing;
import com.sb.web.common.exception.ApiException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
* 실연동 전 스텁. 실제 Google Play 검증은 아직 구성되지 않았다.
* (테스트 모드에서는 BillingService 가 이 검증을 건너뛴다.)
*/
@Component
public class StubGooglePlayVerifier implements GooglePlayVerifier {
@Override
public boolean verify(String productId, String purchaseToken) {
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE,
"구글 결제 검증이 아직 구성되지 않았습니다. (테스트 모드로만 결제 가능)");
}
}
@@ -0,0 +1,28 @@
package com.sb.web.billing.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 인앱 결제 기록 (iap_purchase).
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class IapPurchase {
private Long id;
private Long memberId;
private String platform; // GOOGLE_PLAY / TEST
private String productId;
private String purchaseToken;
private String orderId;
private String status; // VERIFIED / FAILED
private LocalDateTime expiresAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,24 @@
package com.sb.web.billing.dto;
import com.sb.web.auth.domain.Member;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 결제 후 멤버십 상태.
*/
@Data
@AllArgsConstructor
public class MembershipResponse {
private String plan; // FREE / PREMIUM
private LocalDateTime planExpiresAt; // 만료일 (NULL=만료없음)
private boolean premium;
public static MembershipResponse of(Member m) {
boolean premium = "PREMIUM".equals(m.getPlan());
return new MembershipResponse(m.getPlan(), m.getPlanExpiresAt(), premium);
}
}
@@ -0,0 +1,17 @@
package com.sb.web.billing.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 구독 상품 (업그레이드 화면 표시용).
*/
@Data
@AllArgsConstructor
public class ProductResponse {
private String id; // Google Play 상품 ID
private String label; // 표시 이름
private int months; // 부여 개월수
private int priceKrw; // 표시용 가격(원) — 실제 결제 금액은 Play Console 기준(스캐폴드 표시용)
}
@@ -0,0 +1,20 @@
package com.sb.web.billing.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* Google Play 구매 검증 요청.
*/
@Data
public class VerifyRequest {
@NotBlank
private String productId;
/** Play 구매 토큰 (테스트는 'test-' 로 시작) */
@NotBlank
private String purchaseToken;
private String orderId;
}
@@ -0,0 +1,17 @@
package com.sb.web.billing.mapper;
import com.sb.web.billing.domain.IapPurchase;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 인앱 결제 기록 매퍼.
*/
@Mapper
public interface IapPurchaseMapper {
int insert(IapPurchase purchase);
/** 구매 토큰으로 기존 처리 여부 조회 (중복 결제 방지) */
IapPurchase findByToken(@Param("purchaseToken") String purchaseToken);
}
@@ -28,7 +28,7 @@ public class WebConfig implements WebMvcConfigurer {
.addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/point-history", .addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/point-history",
"/api/auth/logout", "/api/auth/password", "/api/auth/logout", "/api/auth/password",
"/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image", "/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image",
"/api/board/**", "/api/admin/**", "/api/account/**"); "/api/board/**", "/api/admin/**", "/api/account/**", "/api/billing/**");
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사) // 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
registry.addInterceptor(adminInterceptor) registry.addInterceptor(adminInterceptor)
.addPathPatterns("/api/admin/**"); .addPathPatterns("/api/admin/**");
+19
View File
@@ -38,6 +38,25 @@ ALTER TABLE member ADD COLUMN IF NOT EXISTS profile_image MEDIUMTEXT NULL COMMEN
-- 포인트(게시판 글/댓글 작성 보상). 기존 회원은 0 으로 시작 (멱등). -- 포인트(게시판 글/댓글 작성 보상). 기존 회원은 0 으로 시작 (멱등).
ALTER TABLE member ADD COLUMN IF NOT EXISTS points BIGINT NOT NULL DEFAULT 0 COMMENT '활동 포인트' AFTER plan; ALTER TABLE member ADD COLUMN IF NOT EXISTS points BIGINT NOT NULL DEFAULT 0 COMMENT '활동 포인트' AFTER plan;
-- 멤버십 만료일 (유료 구독). NULL = 만료 없음(관리자 영구 부여) 또는 무료. 만료 지나면 스케줄러가 FREE 로 강등.
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_expires_at DATETIME NULL COMMENT '유료 멤버십 만료일시' AFTER plan;
-- 인앱 결제 기록 (영수증 검증·중복 방지·감사)
CREATE TABLE IF NOT EXISTS iap_purchase (
id BIGINT NOT NULL AUTO_INCREMENT,
member_id BIGINT NOT NULL,
platform VARCHAR(20) NOT NULL COMMENT 'GOOGLE_PLAY / TEST',
product_id VARCHAR(100) NOT NULL COMMENT '상품 ID(premium_monthly 등)',
purchase_token VARCHAR(512) NOT NULL COMMENT 'Play 구매 토큰(또는 테스트 토큰)',
order_id VARCHAR(120) NULL,
status VARCHAR(20) NOT NULL COMMENT 'VERIFIED / FAILED',
expires_at DATETIME NULL COMMENT '이 결제로 부여된 만료일',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_iap_token (purchase_token),
KEY idx_iap_member (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 포인트 적립/차감 이력 (감사 + 일일 지급 한도 계산용). -- 포인트 적립/차감 이력 (감사 + 일일 지급 한도 계산용).
CREATE TABLE IF NOT EXISTS point_history ( CREATE TABLE IF NOT EXISTS point_history (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
@@ -27,6 +27,10 @@
UPDATE auth_session SET name = #{name} WHERE token = #{token} UPDATE auth_session SET name = #{name} WHERE token = #{token}
</update> </update>
<update id="updatePlan">
UPDATE auth_session SET plan = #{plan} WHERE token = #{token}
</update>
<delete id="delete"> <delete id="delete">
DELETE FROM auth_session WHERE token = #{token} DELETE FROM auth_session WHERE token = #{token}
</delete> </delete>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sb.web.billing.mapper.IapPurchaseMapper">
<resultMap id="iapResultMap" type="com.sb.web.billing.domain.IapPurchase">
<id property="id" column="id"/>
<result property="memberId" column="member_id"/>
<result property="platform" column="platform"/>
<result property="productId" column="product_id"/>
<result property="purchaseToken" column="purchase_token"/>
<result property="orderId" column="order_id"/>
<result property="status" column="status"/>
<result property="expiresAt" column="expires_at"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<insert id="insert" parameterType="com.sb.web.billing.domain.IapPurchase"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO iap_purchase (member_id, platform, product_id, purchase_token, order_id, status, expires_at, created_at)
VALUES (#{memberId}, #{platform}, #{productId}, #{purchaseToken}, #{orderId}, #{status}, #{expiresAt}, NOW())
</insert>
<select id="findByToken" resultMap="iapResultMap">
SELECT id, member_id, platform, product_id, purchase_token, order_id, status, expires_at, created_at
FROM iap_purchase WHERE purchase_token = #{purchaseToken}
</select>
</mapper>
+15 -2
View File
@@ -16,6 +16,7 @@
<result property="profileImage" column="profile_image"/> <result property="profileImage" column="profile_image"/>
<result property="role" column="role"/> <result property="role" column="role"/>
<result property="plan" column="plan"/> <result property="plan" column="plan"/>
<result property="planExpiresAt" column="plan_expires_at"/>
<result property="points" column="points"/> <result property="points" column="points"/>
<result property="status" column="status"/> <result property="status" column="status"/>
<result property="createdAt" column="created_at"/> <result property="createdAt" column="created_at"/>
@@ -23,7 +24,7 @@
</resultMap> </resultMap>
<sql id="columns"> <sql id="columns">
id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, points, status, created_at, updated_at id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, plan_expires_at, points, status, created_at, updated_at
</sql> </sql>
<select id="findById" parameterType="long" resultMap="memberResultMap"> <select id="findById" parameterType="long" resultMap="memberResultMap">
@@ -96,8 +97,20 @@
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id} UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
</update> </update>
<!-- 관리자 수동 변경: 만료일 없이(영구) 설정/해제 -->
<update id="updatePlan"> <update id="updatePlan">
UPDATE member SET plan = #{plan}, updated_at = NOW() WHERE id = #{id} UPDATE member SET plan = #{plan}, plan_expires_at = NULL, updated_at = NOW() WHERE id = #{id}
</update>
<!-- 결제로 멤버십 부여/갱신: plan + 만료일 -->
<update id="updateMembership">
UPDATE member SET plan = #{plan}, plan_expires_at = #{expiresAt}, updated_at = NOW() WHERE id = #{id}
</update>
<!-- 만료된 유료회원 자동 강등 (스케줄러) -->
<update id="downgradeExpired">
UPDATE member SET plan = 'FREE', updated_at = NOW()
WHERE plan = 'PREMIUM' AND plan_expires_at IS NOT NULL AND plan_expires_at &lt; NOW()
</update> </update>
<update id="updateStatus"> <update id="updateStatus">