캠페인 실시간 랭킹 후원자 이름 · 위치 핀 도로명 주소
This commit is contained in:
@@ -1150,7 +1150,7 @@ export async function fundCampaign(input: {
|
|||||||
// 귀속 팬덤은 바뀌지 않는 값이라 트랜잭션 밖에서 한 번 읽는다. 상태 · 목표 · 모금액은
|
// 귀속 팬덤은 바뀌지 않는 값이라 트랜잭션 밖에서 한 번 읽는다. 상태 · 목표 · 모금액은
|
||||||
// 아래 precheck 가 **차감과 같은 트랜잭션에서** 다시 읽는다 — 밖에서 판정하면 동시
|
// 아래 precheck 가 **차감과 같은 트랜잭션에서** 다시 읽는다 — 밖에서 판정하면 동시
|
||||||
// 후원 둘이 같은 잔여분을 보고 함께 통과해 목표를 넘긴다(B-34).
|
// 후원 둘이 같은 잔여분을 보고 함께 통과해 목표를 넘긴다(B-34).
|
||||||
const campaign = await campaignRef.get();
|
const [campaign, donor] = await Promise.all([campaignRef.get(), db.doc(`users/${uid}`).get()]);
|
||||||
if (!campaign.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
|
if (!campaign.exists) fail(ERR.campaignNotApproved, '캠페인을 찾을 수 없습니다.');
|
||||||
const fandomId = campaign.get('fandomId');
|
const fandomId = campaign.get('fandomId');
|
||||||
let goal = 0;
|
let goal = 0;
|
||||||
@@ -1189,6 +1189,8 @@ export async function fundCampaign(input: {
|
|||||||
uid,
|
uid,
|
||||||
// 「기여중」 목록의 collectionGroup 인덱스 키(firestore.indexes.json)와 같은 이름.
|
// 「기여중」 목록의 collectionGroup 인덱스 키(firestore.indexes.json)와 같은 이름.
|
||||||
donorUid: uid,
|
donorUid: uid,
|
||||||
|
// 실시간 랭킹의 이름 — 공개 닉네임(M-7). 다른 후원자의 사용자 문서는 규칙상 읽을 수 없다.
|
||||||
|
donorName: publicNameOf(donor),
|
||||||
amount,
|
amount,
|
||||||
popType,
|
popType,
|
||||||
txId: txIdOf(`fund:${uid}:${campaignId}:${clientSeq}`),
|
txId: txIdOf(`fund:${uid}:${campaignId}:${clientSeq}`),
|
||||||
|
|||||||
@@ -290,6 +290,15 @@ describe('회사귀책 취소 — fn-campaign-admin-cancel (B-58)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('후원 문서에 공개 닉네임(donorName)을 싣는다 — 실시간 랭킹 이름(M-7), 닉네임 없으면 「팬」', async () => {
|
||||||
|
await db().doc(`users/${A}`).set({ nickname: '후원왕', displayName: '김실명' }, { merge: true });
|
||||||
|
await fundedCampaign('cmp-names', 1_000, [[A, 10], [B, 10]]);
|
||||||
|
const rows = (await db().collection('campaigns/cmp-names/donations').get()).docs.map((d) => d.data());
|
||||||
|
expect(rows.find((r) => r.uid === A)?.donorName).toBe('후원왕');
|
||||||
|
expect(rows.find((r) => r.uid === B)?.donorName).toBe('팬');
|
||||||
|
await db().recursiveDelete(db().doc('campaigns/cmp-names'));
|
||||||
|
});
|
||||||
|
|
||||||
describe('정산 분기', () => {
|
describe('정산 분기', () => {
|
||||||
it('목표(targetPop) 달성은 success, 미달은 fail 이다', async () => {
|
it('목표(targetPop) 달성은 success, 미달은 fail 이다', async () => {
|
||||||
await fundedCampaign('cmp-ok', 500, [[A, 300], [B, 200]]);
|
await fundedCampaign('cmp-ok', 500, [[A, 300], [B, 200]]);
|
||||||
|
|||||||
@@ -1001,6 +1001,18 @@ class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
|
|||||||
_spent += applied;
|
_spent += applied;
|
||||||
_raised += applied;
|
_raised += applied;
|
||||||
});
|
});
|
||||||
|
// 실시간 랭킹(상위 3) · 모금액을 서버 값으로 다시 읽는다. 잔액 계산(_balance · _spent)은 그대로 둔다.
|
||||||
|
try {
|
||||||
|
final fresh = await widget.repository.detail(widget.campaignId);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_detail = fresh;
|
||||||
|
_raised = fresh.card.raisedPop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} on Object catch (_) {
|
||||||
|
// 다시 읽기 실패는 후원 결과와 무관하다 — 앞 화면을 그대로 둔다.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openRanking(CampaignDetail detail) =>
|
Future<void> _openRanking(CampaignDetail detail) =>
|
||||||
|
|||||||
@@ -177,12 +177,15 @@ class FirestoreCampaignRepository implements CampaignRepository {
|
|||||||
RankWindow.weekly => DateTime.now().subtract(const Duration(days: 7)),
|
RankWindow.weekly => DateTime.now().subtract(const Duration(days: 7)),
|
||||||
};
|
};
|
||||||
final totals = <String, int>{};
|
final totals = <String, int>{};
|
||||||
|
final names = <String, String>{};
|
||||||
for (final d in snap.docs) {
|
for (final d in snap.docs) {
|
||||||
final m = d.data();
|
final m = d.data();
|
||||||
if (since != null && m.dateOr('createdAt', since).isBefore(since))
|
if (since != null && m.dateOr('createdAt', since).isBefore(since))
|
||||||
continue;
|
continue;
|
||||||
final uid = m.strOr('uid');
|
final uid = m.strOr('uid');
|
||||||
totals[uid] = (totals[uid] ?? 0) + m.intOr('amount');
|
totals[uid] = (totals[uid] ?? 0) + m.intOr('amount');
|
||||||
|
// 서버가 후원 때 실은 공개 닉네임(`donorName`, M-7). 예전 후원에는 없어 「팬」이다.
|
||||||
|
if (m.strOr('donorName').isNotEmpty) names[uid] = m.strOr('donorName');
|
||||||
}
|
}
|
||||||
final me = _backend.uid;
|
final me = _backend.uid;
|
||||||
final rows = totals.entries.toList()
|
final rows = totals.entries.toList()
|
||||||
@@ -191,8 +194,10 @@ class FirestoreCampaignRepository implements CampaignRepository {
|
|||||||
for (var i = 0; i < rows.length; i++)
|
for (var i = 0; i < rows.length; i++)
|
||||||
ContributorRank(
|
ContributorRank(
|
||||||
rank: i + 1,
|
rank: i + 1,
|
||||||
// 남의 이름은 서버에 읽을 자리가 없다. UID 는 화면에 내보내지 않는다.
|
// UID · 이메일은 화면에 내보내지 않는다(G-07) — 공개 닉네임만.
|
||||||
name: rows[i].key == me ? appL10n.commonMe : appL10n.commonFan,
|
name: rows[i].key == me
|
||||||
|
? appL10n.commonMe
|
||||||
|
: publicName(names[rows[i].key]),
|
||||||
pop: rows[i].value,
|
pop: rows[i].value,
|
||||||
isMe: rows[i].key == me,
|
isMe: rows[i].key == me,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -68,12 +68,8 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
|||||||
try {
|
try {
|
||||||
final marks = await Geocoding().placemarkFromCoordinates(point.latitude, point.longitude);
|
final marks = await Geocoding().placemarkFromCoordinates(point.latitude, point.longitude);
|
||||||
if (marks.isNotEmpty) {
|
if (marks.isNotEmpty) {
|
||||||
final m = marks.first;
|
final line = placeAddress(marks.first);
|
||||||
final parts = [m.administrativeArea, m.locality, m.subLocality, m.thoroughfare, m.subThoroughfare]
|
if (line.isNotEmpty) address = line;
|
||||||
.whereType<String>()
|
|
||||||
.where((s) => s.isNotEmpty)
|
|
||||||
.toList();
|
|
||||||
if (parts.isNotEmpty) address = parts.join(' ');
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
|
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
|
||||||
@@ -230,3 +226,26 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 지오코더 결과 → 한 줄 주소. 한국 주소는 도로명이 `thoroughfare` 에 오지 않아(번지만 `subThoroughfare`)
|
||||||
|
/// 칸을 이어 붙이면 「서울특별시 마포구 34」가 된다(10/6 실기기). 도로명이 든 `street` 를 쓴다 —
|
||||||
|
/// Android 는 주소 전체 줄(나라 이름 포함), iOS 는 「도로명 번지」만 준다.
|
||||||
|
String placeAddress(Placemark m) {
|
||||||
|
String t(String? v) => v?.trim() ?? '';
|
||||||
|
final street = t(m.street);
|
||||||
|
final area = t(m.administrativeArea);
|
||||||
|
if (street.isNotEmpty && area.isNotEmpty && street.contains(area)) {
|
||||||
|
// 전체 줄 — 앞의 나라 이름만 뗀다.
|
||||||
|
final country = t(m.country);
|
||||||
|
return country.isNotEmpty && street.startsWith(country)
|
||||||
|
? street.substring(country.length).trim()
|
||||||
|
: street;
|
||||||
|
}
|
||||||
|
final road = street.isNotEmpty
|
||||||
|
? street
|
||||||
|
: [t(m.thoroughfare), t(m.subThoroughfare)].where((s) => s.isNotEmpty).join(' ');
|
||||||
|
return [area, t(m.locality), t(m.subLocality), road]
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:fancake/features/quest/place_picker_screen.dart' show placeAddress;
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:geocoding/geocoding.dart';
|
||||||
|
|
||||||
|
/// 10/6 실기기 — 핀 주소가 「서울특별시 마포구 34」로 도로명이 빠졌다.
|
||||||
|
void main() {
|
||||||
|
test('Android — 전체 주소 줄에서 나라 이름만 뗀다', () {
|
||||||
|
final m = Placemark(
|
||||||
|
country: '대한민국',
|
||||||
|
street: '대한민국 서울특별시 마포구 양화로 34',
|
||||||
|
administrativeArea: '서울특별시',
|
||||||
|
subLocality: '마포구',
|
||||||
|
subThoroughfare: '34',
|
||||||
|
);
|
||||||
|
expect(placeAddress(m), '서울특별시 마포구 양화로 34');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iOS — 「도로명 번지」 street 앞에 시 · 구를 붙인다', () {
|
||||||
|
final m = Placemark(
|
||||||
|
country: '대한민국',
|
||||||
|
street: '양화로 34',
|
||||||
|
administrativeArea: '서울특별시',
|
||||||
|
locality: '마포구',
|
||||||
|
thoroughfare: '양화로',
|
||||||
|
subThoroughfare: '34',
|
||||||
|
);
|
||||||
|
expect(placeAddress(m), '서울특별시 마포구 양화로 34');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('street 가 없으면 도로 · 번지 칸으로', () {
|
||||||
|
final m = Placemark(administrativeArea: '서울특별시', thoroughfare: '양화로', subThoroughfare: '34');
|
||||||
|
expect(placeAddress(m), '서울특별시 양화로 34');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user