diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..28bd7a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + h5: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm run build:h5 + - run: npm run test:h5 + - name: Install Playwright Chromium + working-directory: apps/user-h5 + run: npx playwright install --with-deps chromium + - run: npm run test:e2e -w @yuxingu/user-h5 + env: + CI: true + + api: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Unit tests (no integration DB) + working-directory: apps/api + run: go test $(go list ./... | grep -v /internal/integration) -count=1 + + ess-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: ESS artifact presence (ECR-004) + run: | + test -f docs/ECR/ECR-004-hygiene-followup.md + test -f docs/TASKS/TASK-20260805-ECR004.yaml + test -f docs/TEST_REPORT/ECR-004.md + test -f docs/STATE/ECR-004.md + echo "ESS docs gate OK" + - name: ess-validate when ESS_ROOT available + env: + ESS_ROOT: ${{ vars.ESS_ROOT }} + run: | + if [ -n "$ESS_ROOT" ] && [ -f "$ESS_ROOT/scripts/ess-validate.py" ]; then + python scripts/ess-validate.py --phase coding --ecr ECR-004 --task docs/TASKS/TASK-20260805-ECR004.yaml + else + echo "ESS_ROOT not configured in CI — skipped full ess-validate" + fi diff --git a/apps/api/internal/handler/report.go b/apps/api/internal/handler/report.go index 117d994..3e01a8d 100644 --- a/apps/api/internal/handler/report.go +++ b/apps/api/internal/handler/report.go @@ -19,6 +19,14 @@ type ReportHandler struct { Membership *membership.Service } +func (h *ReportHandler) requireMembership(c *gin.Context) (*membership.Service, bool) { + if h.Membership == nil { + response.Fail(c, http.StatusInternalServerError, 50000, "membership service unavailable") + return nil, false + } + return h.Membership, true +} + // Register mounts report/commerce routes. func (h *ReportHandler) Register(rg *gin.RouterGroup) { rg.POST("/reports/portrait", h.CreatePortrait) @@ -202,7 +210,11 @@ func (h *ReportHandler) GetMembership(c *gin.Context) { response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") return } - me, err := h.Membership.Get(c.Request.Context(), userID) + msvc, ok := h.requireMembership(c) + if !ok { + return + } + me, err := msvc.Get(c.Request.Context(), userID) if err != nil { response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) return @@ -217,6 +229,10 @@ func (h *ReportHandler) CreateOrder(c *gin.Context) { response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") return } + msvc, ok := h.requireMembership(c) + if !ok { + return + } var req struct { Kind string `json:"kind" binding:"required"` Plan string `json:"plan"` @@ -235,7 +251,7 @@ func (h *ReportHandler) CreateOrder(c *gin.Context) { } rid = &id } - oid, err := h.Membership.CreateOrder(c.Request.Context(), userID, membership.CreateOrderInput{ + oid, err := msvc.CreateOrder(c.Request.Context(), userID, membership.CreateOrderInput{ Kind: req.Kind, Plan: req.Plan, ReportID: rid, }) if err != nil { @@ -252,12 +268,16 @@ func (h *ReportHandler) PayMock(c *gin.Context) { response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") return } + msvc, ok := h.requireMembership(c) + if !ok { + return + } oid, err := uuid.Parse(c.Param("id")) if err != nil { response.Fail(c, http.StatusBadRequest, 10000, "invalid id") return } - if err := h.Membership.PayMock(c.Request.Context(), userID, oid); err != nil { + if err := msvc.PayMock(c.Request.Context(), userID, oid); err != nil { response.Fail(c, http.StatusBadRequest, 30004, err.Error()) return } diff --git a/apps/user-h5/src/components/synastry/SynastryClassicSelects.vue b/apps/user-h5/src/components/synastry/SynastryClassicSelects.vue new file mode 100644 index 0000000..354e7e2 --- /dev/null +++ b/apps/user-h5/src/components/synastry/SynastryClassicSelects.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/apps/user-h5/src/components/synastry/SynastryDualPick.vue b/apps/user-h5/src/components/synastry/SynastryDualPick.vue new file mode 100644 index 0000000..674be5b --- /dev/null +++ b/apps/user-h5/src/components/synastry/SynastryDualPick.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/apps/user-h5/src/components/synastry/SynastryInviteNearby.vue b/apps/user-h5/src/components/synastry/SynastryInviteNearby.vue new file mode 100644 index 0000000..eadd4e9 --- /dev/null +++ b/apps/user-h5/src/components/synastry/SynastryInviteNearby.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/apps/user-h5/src/components/synastry/SynastryLanding.vue b/apps/user-h5/src/components/synastry/SynastryLanding.vue index 3d71931..1c83274 100644 --- a/apps/user-h5/src/components/synastry/SynastryLanding.vue +++ b/apps/user-h5/src/components/synastry/SynastryLanding.vue @@ -2,75 +2,35 @@

看看你和 TA 的默契指数

-
- -
+ -
-
-
- {{ selfInitial }} -
- 自己 - {{ profileAName }} -
- -
-
- - - -
-
-
+ -
-

快速添加 TA

- - - -
+ - -
-

邀请链接:{{ invitePath }}(可复制分享)

-

{{ nearbyHint }}

-

- 需要至少两个档案。可先去 - 档案页 - 添加 TA,或点「添加档案」快速创建。 -

+ @@ -131,9 +79,12 @@ + + diff --git a/apps/user-h5/src/components/synastry/SynastryRelationChips.vue b/apps/user-h5/src/components/synastry/SynastryRelationChips.vue new file mode 100644 index 0000000..dd73deb --- /dev/null +++ b/apps/user-h5/src/components/synastry/SynastryRelationChips.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/apps/user-h5/src/composables/synastryPageActions.ts b/apps/user-h5/src/composables/synastryPageActions.ts new file mode 100644 index 0000000..e0500a9 --- /dev/null +++ b/apps/user-h5/src/composables/synastryPageActions.ts @@ -0,0 +1,157 @@ +import type { Ref } from 'vue' +import type { GrowthReport, Profile } from '@yuxingu/types' +import { validateBirth } from '@yuxingu/utils' +import { api } from '../api/client' +import { AnalyticsEvent, track } from '../lib/analytics' +import type { SynastryMainTab } from '../lib/synastryChart' + +type NearbyItem = { profile: Profile; distance_km: number } + +export type SynastryPageActionRefs = { + loading: Ref + adding: Ref + paying: Ref + inviting: Ref + nearbyLoading: Ref + error: Ref + nearbyHint: Ref + invitePath: Ref + profiles: Ref + nearby: Ref + profileA: Ref + profileB: Ref + asOf: Ref + report: Ref + ty: Ref + tm: Ref + td: Ref + tName: Ref + mainTab: Ref + showQuickAdd: Ref +} + +export function createSynastryPageActions(r: SynastryPageActionRefs) { + async function loadProfiles() { + try { + const res = await api.listProfiles() + r.profiles.value = res.items || [] + const self = r.profiles.value.find((p) => p.relation === 'self') + if (self && !r.profileA.value) r.profileA.value = self.id + const other = r.profiles.value.find((p) => p.id !== r.profileA.value) + if (other && !r.profileB.value) r.profileB.value = other.id + } catch (e) { + r.error.value = e instanceof Error ? e.message : '加载档案失败' + } + } + + async function addTemp() { + const y = Number(r.ty.value) + const m = Number(r.tm.value) + const d = Number(r.td.value) + const msg = validateBirth(y, m, d) + if (msg) { + r.error.value = msg + return + } + r.adding.value = true + r.error.value = '' + try { + const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` + const p = await api.createProfile({ + relation: 'other', + birth_date: birth, + display_name: r.tName.value || 'TA', + }) + await loadProfiles() + r.profileB.value = p.id + r.showQuickAdd.value = false + } catch (e) { + r.error.value = e instanceof Error ? e.message : '添加失败' + } finally { + r.adding.value = false + } + } + + async function generate() { + if (!r.profileA.value || !r.profileB.value) return + r.loading.value = true + r.error.value = '' + try { + r.report.value = await api.createSynastry(r.profileA.value, r.profileB.value, r.asOf.value) + r.mainTab.value = 'compare' + track(AnalyticsEvent.SynastryCompleted, { source: 'synastry' }) + } catch (e) { + r.error.value = e instanceof Error ? e.message : '合盘失败' + } finally { + r.loading.value = false + } + } + + async function createInvite() { + if (!r.profileA.value) return + r.inviting.value = true + r.error.value = '' + try { + const res = await api.createSynastryInvite(r.profileA.value) + r.invitePath.value = res.path + track(AnalyticsEvent.SynastryInviteCreated, { token: res.token }) + } catch (e) { + r.error.value = e instanceof Error ? e.message : '邀请失败' + } finally { + r.inviting.value = false + } + } + + async function loadNearby() { + r.nearbyLoading.value = true + r.nearbyHint.value = '' + r.error.value = '' + try { + const pos = await new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject(new Error('当前环境不支持定位')) + return + } + navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 8000 }) + }) + const lat = pos.coords.latitude + const lng = pos.coords.longitude + const self = r.profiles.value.find((p) => p.relation === 'self') + if (self) { + await api.updateProfile(self.id, { geo_lat: lat, geo_lng: lng }) + } + const res = await api.listSynastryNearby(lat, lng, 50) + r.nearby.value = res.items || [] + r.nearbyHint.value = r.nearby.value.length + ? `找到 ${r.nearby.value.length} 位附近可合盘对象。若希望别人看到你,请在档案中开启「位置可见」。` + : '附近暂无已开启位置可见的档案;可邀请好友或手动添加。需要被看到时请在档案开启位置可见。' + track(AnalyticsEvent.SynastryNearbyOpened, { count: r.nearby.value.length }) + } catch (e) { + r.nearbyHint.value = e instanceof Error ? e.message : '定位失败,可手动选城后在档案页开启位置' + } finally { + r.nearbyLoading.value = false + } + } + + async function buyDeep() { + if (!r.report.value) return + r.paying.value = true + track(AnalyticsEvent.DeepAccessClicked, { surface: 'synastry' }) + try { + const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: r.report.value.id }) + await api.payMock(order_id) + r.report.value = await api.getReport(r.report.value.id) + track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' }) + } catch (e) { + r.error.value = e instanceof Error ? e.message : '支付失败' + } finally { + r.paying.value = false + } + } + + function reset() { + r.report.value = null + } + + return { loadProfiles, addTemp, generate, createInvite, loadNearby, buyDeep, reset } +} diff --git a/apps/user-h5/src/composables/useSynastryPage.ts b/apps/user-h5/src/composables/useSynastryPage.ts index a493822..842f9b9 100644 --- a/apps/user-h5/src/composables/useSynastryPage.ts +++ b/apps/user-h5/src/composables/useSynastryPage.ts @@ -1,23 +1,27 @@ import { computed, onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import type { GrowthReport, Profile } from '@yuxingu/types' -import { validateBirth } from '@yuxingu/utils' -import { api } from '../api/client' -import type { WheelPlanet } from '../components/NatalWheel.vue' -import { AnalyticsEvent, track } from '../lib/analytics' -import type { RelationSharePayload } from '../lib/shareLink' +import { + asPlanets, + ascLonFromChart, + birthLabel, + buildRelationSharePayload, + chartBlock, + chartPlanetsFromSummary, + chartTip as chartTipFromCharts, + labelList, + mainTabs, + overlayEntriesFrom, + profileInitial, + relationTypes, + resolveActiveChartKey, + sectionsFromDetail, + type SynastryMainTab, +} from '../lib/synastryChart' +import { createSynastryPageActions } from './synastryPageActions' -export type MainTab = 'compare' | 'composite' | 'davison' | 'marks' | 'overlay' - -export const relationTypes = ['伴侣', '朋友', '家人', '其他'] - -export const mainTabs: { key: MainTab; label: string }[] = [ - { key: 'compare', label: '比较' }, - { key: 'composite', label: '组合' }, - { key: 'davison', label: '时空' }, - { key: 'marks', label: '马克斯' }, - { key: 'overlay', label: '配对' }, -] +export type MainTab = SynastryMainTab +export { relationTypes, mainTabs, birthLabel, profileInitial } export function useSynastryPage() { const route = useRoute() @@ -51,22 +55,12 @@ export function useSynastryPage() { const showQuickAdd = ref(false) const needsSubTab = computed(() => ['composite', 'davison', 'marks'].includes(mainTab.value)) - const pickableProfiles = computed(() => profiles.value.filter((p) => p.id !== profileA.value)) - const profileAName = computed(() => { const p = profiles.value.find((x) => x.id === profileA.value) return p?.display_name || '' }) - - const selfInitial = computed(() => { - const name = profileAName.value || '我' - return name.slice(0, 1) - }) - - function profileInitial(p: Profile) { - return (p.display_name || 'TA').slice(0, 1) - } + const selfInitial = computed(() => (profileAName.value || '我').slice(0, 1)) const summary = computed(() => (report.value?.summary || {}) as Record) const detail = computed(() => (report.value?.detail || null) as Record | null) @@ -79,237 +73,66 @@ export function useSynastryPage() { const loveNote = computed(() => String(summary.value.love_note || '')) const asOfLabel = computed(() => String(summary.value.as_of || asOf.value)) - function asPlanets(raw: unknown): WheelPlanet[] { - if (!Array.isArray(raw)) return [] - return raw.map((p) => { - const o = p as Record - return { - key: String(o.key), - title: String(o.title), - sign: String(o.sign), - degree: String(o.degree), - house: Number(o.house), - lon: Number(o.lon), - element: o.element != null ? String(o.element) : undefined, - modality: o.modality != null ? String(o.modality) : undefined, - } - }) - } - - function chartPlanets(key: 'chart_a' | 'chart_b'): WheelPlanet[] { - const c = summary.value[key] - if (!c || typeof c !== 'object') return [] - return asPlanets((c as { planets?: unknown }).planets) - } - - const planetsA = computed(() => chartPlanets('chart_a')) - const planetsB = computed(() => chartPlanets('chart_b')) - const ascA = computed(() => { - const c = summary.value.chart_a as { asc_lon?: number } | undefined - return typeof c?.asc_lon === 'number' ? c.asc_lon : null - }) - const ascB = computed(() => { - const c = summary.value.chart_b as { asc_lon?: number } | undefined - return typeof c?.asc_lon === 'number' ? c.asc_lon : null - }) - const aspectPreview = computed(() => { - const raw = summary.value.aspects_preview - return Array.isArray(raw) ? (raw as { label: string }[]) : [] - }) - - function chartBlock(key: string): Record | null { - const c = charts.value[key] - if (!c || typeof c !== 'object') return null - return c as Record - } + const planetsA = computed(() => chartPlanetsFromSummary(summary.value, 'chart_a')) + const planetsB = computed(() => chartPlanetsFromSummary(summary.value, 'chart_b')) + const ascA = computed(() => ascLonFromChart(summary.value.chart_a)) + const ascB = computed(() => ascLonFromChart(summary.value.chart_b)) + const aspectPreview = computed(() => labelList(summary.value.aspects_preview)) function chartTip(key: string): string { - const c = chartBlock(key) - return String(c?.tip || '') + return chartTipFromCharts(charts.value, key) } - const activeChartKey = computed(() => { - if (mainTab.value === 'composite') { - return subTab.value === 'prog' ? 'composite_progressed' : 'composite' - } - if (mainTab.value === 'davison') { - return subTab.value === 'prog' ? 'davison_progressed' : 'davison' - } - if (mainTab.value === 'marks') { - if (subTab.value === 'prog') return 'marks_progressed' - return marksWho.value === 'other' ? 'marks_other' : 'marks_me' - } - return '' - }) - - const activePlanets = computed(() => { - const c = chartBlock(activeChartKey.value) - return asPlanets(c?.planets) - }) + const activeChartKey = computed(() => + resolveActiveChartKey(mainTab.value, subTab.value, marksWho.value), + ) + const activePlanets = computed(() => asPlanets(chartBlock(charts.value, activeChartKey.value)?.planets)) const activeAsc = computed(() => { - const c = chartBlock(activeChartKey.value) - return typeof c?.asc_lon === 'number' ? (c.asc_lon as number) : null - }) - const activeAspectPreview = computed(() => { - const c = chartBlock(activeChartKey.value) - const raw = c?.aspects_preview - return Array.isArray(raw) ? (raw as { label: string }[]) : [] + const lon = chartBlock(charts.value, activeChartKey.value)?.asc_lon + return typeof lon === 'number' ? lon : null }) + const activeAspectPreview = computed(() => + labelList(chartBlock(charts.value, activeChartKey.value)?.aspects_preview), + ) const activeChartTip = computed(() => chartTip(activeChartKey.value)) + const overlayTip = computed(() => String(chartBlock(charts.value, 'overlay')?.tip || '')) + const overlayEntries = computed(() => overlayEntriesFrom(charts.value)) + const fullAspects = computed(() => labelList(detail.value?.aspects)) + const sections = computed(() => sectionsFromDetail(detail.value)) + const sharePayload = computed(() => + buildRelationSharePayload( + report.value, + summary.value, + headline.value, + love.value, + friend.value, + marriage.value, + ), + ) - const overlayTip = computed(() => String(chartBlock('overlay')?.tip || '')) - const overlayEntries = computed(() => { - const raw = chartBlock('overlay')?.entries - return Array.isArray(raw) - ? (raw as { planet: string; sign: string; house: number; house_tip: string }[]) - : [] - }) - - const fullAspects = computed(() => { - const raw = detail.value?.aspects - return Array.isArray(raw) ? (raw as { label: string }[]) : [] - }) - const sections = computed(() => { - const raw = detail.value?.sections - return Array.isArray(raw) ? (raw as { title: string; body: string }[]) : [] - }) - const sharePayload = computed(() => { - if (!report.value) return null - return { - type: 'relation', - me: String(summary.value.me_name || '我'), - other: String(summary.value.other_name || 'TA'), - diff: headline.value, - keywords: [`恋爱${love.value}`, `友情${friend.value}`, `婚姻${marriage.value}`], - } - }) - - function birthLabel(d?: string) { - if (!d) return '' - return String(d).slice(0, 10) - } - - async function loadProfiles() { - try { - const res = await api.listProfiles() - profiles.value = res.items || [] - const self = profiles.value.find((p) => p.relation === 'self') - if (self && !profileA.value) profileA.value = self.id - const other = profiles.value.find((p) => p.id !== profileA.value) - if (other && !profileB.value) profileB.value = other.id - } catch (e) { - error.value = e instanceof Error ? e.message : '加载档案失败' - } - } - - async function addTemp() { - const y = Number(ty.value) - const m = Number(tm.value) - const d = Number(td.value) - const msg = validateBirth(y, m, d) - if (msg) { - error.value = msg - return - } - adding.value = true - error.value = '' - try { - const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` - const p = await api.createProfile({ - relation: 'other', - birth_date: birth, - display_name: tName.value || 'TA', - }) - await loadProfiles() - profileB.value = p.id - showQuickAdd.value = false - } catch (e) { - error.value = e instanceof Error ? e.message : '添加失败' - } finally { - adding.value = false - } - } - - async function generate() { - if (!profileA.value || !profileB.value) return - loading.value = true - error.value = '' - try { - report.value = await api.createSynastry(profileA.value, profileB.value, asOf.value) - mainTab.value = 'compare' - track(AnalyticsEvent.SynastryCompleted, { source: 'synastry' }) - } catch (e) { - error.value = e instanceof Error ? e.message : '合盘失败' - } finally { - loading.value = false - } - } - - async function createInvite() { - if (!profileA.value) return - inviting.value = true - error.value = '' - try { - const res = await api.createSynastryInvite(profileA.value) - invitePath.value = res.path - track(AnalyticsEvent.SynastryInviteCreated, { token: res.token }) - } catch (e) { - error.value = e instanceof Error ? e.message : '邀请失败' - } finally { - inviting.value = false - } - } - - async function loadNearby() { - nearbyLoading.value = true - nearbyHint.value = '' - error.value = '' - try { - const pos = await new Promise((resolve, reject) => { - if (!navigator.geolocation) { - reject(new Error('当前环境不支持定位')) - return - } - navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 8000 }) - }) - const lat = pos.coords.latitude - const lng = pos.coords.longitude - const self = profiles.value.find((p) => p.relation === 'self') - if (self) { - await api.updateProfile(self.id, { geo_lat: lat, geo_lng: lng }) - } - const res = await api.listSynastryNearby(lat, lng, 50) - nearby.value = res.items || [] - nearbyHint.value = nearby.value.length - ? `找到 ${nearby.value.length} 位附近可合盘对象。若希望别人看到你,请在档案中开启「位置可见」。` - : '附近暂无已开启位置可见的档案;可邀请好友或手动添加。需要被看到时请在档案开启位置可见。' - track(AnalyticsEvent.SynastryNearbyOpened, { count: nearby.value.length }) - } catch (e) { - nearbyHint.value = e instanceof Error ? e.message : '定位失败,可手动选城后在档案页开启位置' - } finally { - nearbyLoading.value = false - } - } - - async function buyDeep() { - if (!report.value) return - paying.value = true - track(AnalyticsEvent.DeepAccessClicked, { surface: 'synastry' }) - try { - const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id }) - await api.payMock(order_id) - report.value = await api.getReport(report.value.id) - track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' }) - } catch (e) { - error.value = e instanceof Error ? e.message : '支付失败' - } finally { - paying.value = false - } - } - - function reset() { - report.value = null - } + const { loadProfiles, addTemp, generate, createInvite, loadNearby, buyDeep, reset } = + createSynastryPageActions({ + loading, + adding, + paying, + inviting, + nearbyLoading, + error, + nearbyHint, + invitePath, + profiles, + nearby, + profileA, + profileB, + asOf, + report, + ty, + tm, + td, + tName, + mainTab, + showQuickAdd, + }) onMounted(async () => { await loadProfiles() diff --git a/apps/user-h5/src/lib/synastryChart.ts b/apps/user-h5/src/lib/synastryChart.ts new file mode 100644 index 0000000..a6337a0 --- /dev/null +++ b/apps/user-h5/src/lib/synastryChart.ts @@ -0,0 +1,129 @@ +import type { GrowthReport, Profile } from '@yuxingu/types' +import type { WheelPlanet } from '../components/NatalWheel.vue' +import type { RelationSharePayload } from './shareLink' + +export type SynastryMainTab = 'compare' | 'composite' | 'davison' | 'marks' | 'overlay' +export type SynastrySubTab = 'natal' | 'prog' +export type SynastryMarksWho = 'me' | 'other' + +export const relationTypes = ['伴侣', '朋友', '家人', '其他'] + +export const mainTabs: { key: SynastryMainTab; label: string }[] = [ + { key: 'compare', label: '比较' }, + { key: 'composite', label: '组合' }, + { key: 'davison', label: '时空' }, + { key: 'marks', label: '马克斯' }, + { key: 'overlay', label: '配对' }, +] + +export function asPlanets(raw: unknown): WheelPlanet[] { + if (!Array.isArray(raw)) return [] + return raw.map((p) => { + const o = p as Record + return { + key: String(o.key), + title: String(o.title), + sign: String(o.sign), + degree: String(o.degree), + house: Number(o.house), + lon: Number(o.lon), + element: o.element != null ? String(o.element) : undefined, + modality: o.modality != null ? String(o.modality) : undefined, + } + }) +} + +export function chartBlock( + charts: Record, + key: string, +): Record | null { + const c = charts[key] + if (!c || typeof c !== 'object') return null + return c as Record +} + +export function chartTip(charts: Record, key: string): string { + return String(chartBlock(charts, key)?.tip || '') +} + +export function chartPlanetsFromSummary( + summary: Record, + key: 'chart_a' | 'chart_b', +): WheelPlanet[] { + const c = summary[key] + if (!c || typeof c !== 'object') return [] + return asPlanets((c as { planets?: unknown }).planets) +} + +export function ascLonFromChart(chart: unknown): number | null { + const c = chart as { asc_lon?: number } | undefined + return typeof c?.asc_lon === 'number' ? c.asc_lon : null +} + +export function labelList(raw: unknown): { label: string }[] { + return Array.isArray(raw) ? (raw as { label: string }[]) : [] +} + +export function resolveActiveChartKey( + mainTab: SynastryMainTab, + subTab: SynastrySubTab, + marksWho: SynastryMarksWho, +): string { + if (mainTab === 'composite') { + return subTab === 'prog' ? 'composite_progressed' : 'composite' + } + if (mainTab === 'davison') { + return subTab === 'prog' ? 'davison_progressed' : 'davison' + } + if (mainTab === 'marks') { + if (subTab === 'prog') return 'marks_progressed' + return marksWho === 'other' ? 'marks_other' : 'marks_me' + } + return '' +} + +export type OverlayEntry = { + planet: string + sign: string + house: number + house_tip: string +} + +export function overlayEntriesFrom(charts: Record): OverlayEntry[] { + const raw = chartBlock(charts, 'overlay')?.entries + return Array.isArray(raw) ? (raw as OverlayEntry[]) : [] +} + +export type SynastrySection = { title: string; body: string } + +export function sectionsFromDetail(detail: Record | null): SynastrySection[] { + const raw = detail?.sections + return Array.isArray(raw) ? (raw as SynastrySection[]) : [] +} + +export function buildRelationSharePayload( + report: GrowthReport | null, + summary: Record, + headline: string, + love: number, + friend: number, + marriage: number, +): RelationSharePayload | null { + if (!report) return null + return { + type: 'relation', + me: String(summary.me_name || '我'), + other: String(summary.other_name || 'TA'), + diff: headline, + keywords: [`恋爱${love}`, `友情${friend}`, `婚姻${marriage}`], + } +} + +export function birthLabel(d?: string) { + if (!d) return '' + return String(d).slice(0, 10) +} + +export function profileInitial(p: Profile) { + return (p.display_name || 'TA').slice(0, 1) +} diff --git a/apps/user-h5/src/pages/ScalePage.spec.ts b/apps/user-h5/src/pages/ScalePage.spec.ts new file mode 100644 index 0000000..29abde4 --- /dev/null +++ b/apps/user-h5/src/pages/ScalePage.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import ScalePage from './ScalePage.vue' + +const { api } = vi.hoisted(() => ({ + api: { + getScale: vi.fn(), + listProfiles: vi.fn(), + submitScale: vi.fn(), + }, +})) + +vi.mock('../api/client', () => ({ api })) +vi.mock('../lib/scaleDraft', () => ({ + loadScaleDraft: () => null, + saveScaleDraft: vi.fn(), + clearScaleDraft: vi.fn(), +})) +vi.mock('vue-router', () => ({ + useRoute: () => ({ params: { slug: 'mbti-lite' } }), +})) + +const scaleDetail = { + slug: 'mbti-lite', + title: 'MBTI 轻测', + description: '探索向', + questions: [ + { + id: 'q1', + sort: 1, + body: { + prompt: '第一题?', + options: [ + { key: 'a', text: '选项A' }, + { key: 'b', text: '选项B' }, + ], + }, + }, + { + id: 'q2', + sort: 2, + body: { + prompt: '第二题?', + options: [ + { key: 'a', text: '选项A' }, + { key: 'b', text: '选项B' }, + ], + }, + }, + ], +} + +describe('ScalePage', () => { + beforeEach(() => { + vi.clearAllMocks() + api.getScale.mockResolvedValue(scaleDetail) + api.listProfiles.mockResolvedValue({ + items: [{ id: 'p1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }], + }) + api.submitScale.mockResolvedValue({ + id: 'res1', + result: { label: '探索结果', share_line: '分享一句', summary: '摘要' }, + }) + }) + + it('selects answers via emit then submits on last next', async () => { + const w = mount(ScalePage, { + global: { stubs: { RouterLink: true, ShareSheet: true, HomeToolIcon: true, BackButton: true } }, + }) + await flushPromises() + expect(w.text()).toContain('MBTI 轻测') + + await w.find('button.intro-start').trigger('click') + await flushPromises() + expect(w.text()).toContain('第一题?') + + const radios = w.findAll('input[type="radio"]') + expect(radios.length).toBeGreaterThan(0) + await radios[0].setValue() + await radios[0].trigger('change') + await flushPromises() + + const next = w.findAll('button').find((b) => b.text().includes('下一题')) + expect(next).toBeTruthy() + await next!.trigger('click') + await flushPromises() + expect(w.text()).toContain('第二题?') + + const radios2 = w.findAll('input[type="radio"]') + await radios2[0].setValue() + await radios2[0].trigger('change') + await flushPromises() + + const submitBtn = w.findAll('button').find((b) => b.text().includes('查看探索结果')) + expect(submitBtn).toBeTruthy() + await submitBtn!.trigger('click') + await flushPromises() + + expect(api.submitScale).toHaveBeenCalledWith( + 'mbti-lite', + 'p1', + expect.objectContaining({ q1: 'a', q2: 'a' }), + ) + expect(w.text()).toContain('探索结果') + }) +}) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 50015e1..7dac0a9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,3 +12,4 @@ - **ECR-001 Phase F + H5**:types/sdk 对齐 OpenAPI;拆分 8 个超标页(均 ≤400);test:h5 35/35 绿 - QA:修 Playwright `/psy/` 路径与过时断言;e2e 2/2 绿(见 TEST_REPORT/ECR-001-phaseF-qa.md) - **ECR-001 Closed**;**ECR-002 Closed**;**ECR-003**:删 JSON `fortune` 双写,`lucky`→`boost`,H5 改读 `outlook` +- **ECR-004**:Synastry 再拆 · Scale vitest · Membership nil 守卫 · OpenAPI schemas · GitHub CI diff --git a/docs/CODE_REVIEW/ECR-004.md b/docs/CODE_REVIEW/ECR-004.md new file mode 100644 index 0000000..7656179 --- /dev/null +++ b/docs/CODE_REVIEW/ECR-004.md @@ -0,0 +1,13 @@ +# CODE_REVIEW — ECR-004 + +**Decision: PASS / Approve** + +## Blockers +无 + +## Nits +- OpenAPI 仍有大量 path 仅 `description: OK`(关键 schema 已补;可后续全挂 `$ref`) +- CI `ess-validate` 依赖仓库变量 `ESS_ROOT`;未配置时仅跑文档存在性门禁 + +## Gate +PASS diff --git a/docs/ECR/ECR-004-hygiene-followup.md b/docs/ECR/ECR-004-hygiene-followup.md new file mode 100644 index 0000000..2073d8b --- /dev/null +++ b/docs/ECR/ECR-004-hygiene-followup.md @@ -0,0 +1,35 @@ +# ECR-004 + +**Title:** 结构卫生跟进(Synastry 再拆 · Scale 测 · Membership 守卫 · OpenAPI · CI) +**Status:** Approved +**Date:** 2026-08-05 +**Change Level:** L2(结构 / 契约文档 / 工程门禁;无产品扩行为) + +## Change + +1. 拆分贴线合盘:`useSynastryPage` / `SynastryLanding` +2. Scale 选答交互 vitest +3. `ReportHandler` Membership nil 守卫 +4. OpenAPI 补关键 schemas(对齐 `packages/types`)并挂关键 path `$ref` +5. GitHub Actions CI:build/test H5 · API unit · ESS 文档门禁 + +## Forbidden + +- 改公共 URL / 计分算法 / 产品文案改版 +- Docker-only 本地工作流 + +## Acceptance Criteria + +- [x] SynastryLanding / useSynastryPage ≤300 行 +- [x] ScalePage.spec 覆盖选答→提交 +- [x] Membership nil → 500 信封而非 panic +- [x] OpenAPI 含 Profile / GrowthReport / MembershipMe / Scale* / AskQuota 等 +- [x] `.github/workflows/ci.yml` 存在 +- [x] `test:h5` · star/handler 相关 go test 绿 + +**Status:** coding/review PASS · awaiting Human commit + +## Linked + +- ENGINEERING_SPEC / TASK / STATE / TEST_REPORT under ECR-004 +- Successor of ECR-001 residual nits diff --git a/docs/ENGINEERING_SPEC/ECR-004-hygiene-followup.md b/docs/ENGINEERING_SPEC/ECR-004-hygiene-followup.md new file mode 100644 index 0000000..be63b3d --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-004-hygiene-followup.md @@ -0,0 +1,11 @@ +# ENGINEERING_SPEC — ECR-004 Hygiene Follow-up + +Move/extract only. See ECR-004. + +## Steps done in coding + +1. Synastry: `lib/synastryChart.ts` · `synastryPageActions.ts` · Landing 子组件 +2. `ScalePage.spec.ts` +3. `handler/report.go` `requireMembership` +4. `proto/openapi.yaml` schemas + key `$ref` +5. `.github/workflows/ci.yml` diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index 61f521d..2e442f5 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -45,9 +45,9 @@ ## Active anchors -- ECR: **ECR-003** outlook JSON hygiene(Approved · coding);ECR-001/002 **Closed** +- ECR: **ECR-004** hygiene follow-up(Approved);ECR-001/002/003 Closed / shipped - EXP: (无) -- STATE: `docs/STATE/ECR-003.md` +- STATE: `docs/STATE/ECR-004.md` - TRACEABILITY: `docs/TRACEABILITY.md` - ADR: `.ai/adr/0007-ess-ai-dual-track.md` - Product status: `.ai/product/p1-status.md`(**P1 Complete**) diff --git a/docs/STATE/ECR-004.md b/docs/STATE/ECR-004.md new file mode 100644 index 0000000..53c9b7f --- /dev/null +++ b/docs/STATE/ECR-004.md @@ -0,0 +1,9 @@ +# STATE — ECR-004 + +| Field | Value | +|-------|--------| +| ECR | ECR-004 | +| Title | Hygiene follow-up | +| Phase | review PASS · awaiting commit | +| Owner | human | +| Updated | 2026-08-05 | diff --git a/docs/TASKS/TASK-20260805-ECR004.yaml b/docs/TASKS/TASK-20260805-ECR004.yaml new file mode 100644 index 0000000..6a9969a --- /dev/null +++ b/docs/TASKS/TASK-20260805-ECR004.yaml @@ -0,0 +1,28 @@ +# docs/TASKS/TASK-20260805-ECR004.yaml +task_id: ECR-004-hygiene +role: engineer +phase: coding +tool: cursor +status: coded + +input: + - docs/ECR/ECR-004-hygiene-followup.md + - .ai/coding.md + +output: + - apps/user-h5/src/components/synastry/** + - apps/user-h5/src/composables/useSynastryPage.ts + - apps/user-h5/src/pages/ScalePage.spec.ts + - apps/api/internal/handler/report.go + - proto/openapi.yaml + - .github/workflows/ci.yml + - docs/TEST_REPORT/ECR-004.md + +forbidden: + - redesign_scope + - api_url_change + - behavior_change + +next_agent: reviewer +notes: > + Synastry split, Scale vitest, membership nil guard, OpenAPI schemas, CI. Tests green. diff --git a/docs/TEST_REPORT/ECR-004.md b/docs/TEST_REPORT/ECR-004.md new file mode 100644 index 0000000..1200e79 --- /dev/null +++ b/docs/TEST_REPORT/ECR-004.md @@ -0,0 +1,13 @@ +# Test Report — ECR-004 + +## Cases +- [x] SynastryLanding 165 · useSynastryPage 213(≤300) +- [x] ScalePage.spec 选答→提交 PASS +- [x] SynastryPage.spec PASS +- [x] Membership `requireMembership` nil 守卫 +- [x] OpenAPI schemas + key path `$ref` +- [x] CI workflow `.github/workflows/ci.yml` +- [x] `npm run test:h5` · `go test ./internal/handler/... ./internal/star/...` · `build:h5` + +## Gate +coding PASS · awaiting review/commit diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 03aa4c0..fff24dd 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -9,3 +9,4 @@ | ECR-001-F/H5 | types/sdk + 8 超标页拆分 | Closed | TEST_REPORT · CODE_REVIEW/ECR-001-phaseF-h5.md | | ECR-002 | fortune→outlook package | **Closed** | `docs/ECR/ECR-002-fortune-hygiene.md` | | ECR-003 | outlook JSON hygiene(删 fortune / lucky→boost) | review PASS | TEST_REPORT · CODE_REVIEW/ECR-003.md | +| ECR-004 | Synastry 再拆 · Scale 测 · Membership 守卫 · OpenAPI · CI | review PASS | TEST_REPORT · CODE_REVIEW/ECR-004.md | diff --git a/proto/openapi.yaml b/proto/openapi.yaml index e100ff6..c32eb18 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -50,7 +50,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope' + $ref: '#/components/schemas/EnvelopeProfileList' post: tags: [profile] summary: 创建个人档案 @@ -372,6 +372,10 @@ paths: responses: '200': description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopeScaleList' /api/v1/scales/{slug}: get: @@ -416,6 +420,10 @@ paths: responses: '200': description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopeAskQuota' /api/v1/ask/threads: post: @@ -505,6 +513,10 @@ paths: responses: '200': description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopeMembershipMe' /api/v1/orders: post: @@ -566,3 +578,181 @@ components: kind: { type: string, enum: [membership, deep_access] } plan: { type: string, description: month|quarter|year when membership } report_id: { type: string, format: uuid, description: when deep_access } + + # --- Aligned with packages/types (ECR-004) --- + Profile: + type: object + required: [id, user_id, relation, display_name, birth_date, created_at] + properties: + id: { type: string, format: uuid } + user_id: { type: string, format: uuid } + relation: { type: string, enum: [self, other] } + display_name: { type: string } + birth_date: { type: string, format: date } + birth_time: { type: string, nullable: true } + birth_place: { type: string, nullable: true } + relation_type: { type: string, nullable: true } + geo_lat: { type: number, nullable: true } + geo_lng: { type: number, nullable: true } + geo_visible: { type: boolean } + created_at: { type: string, format: date-time } + + GrowthReport: + type: object + required: [id, user_id, profile_id, type, summary, has_deep_access, created_at] + properties: + id: { type: string, format: uuid } + user_id: { type: string, format: uuid } + profile_id: { type: string, format: uuid } + type: { type: string } + summary: { type: object, additionalProperties: true } + detail: { type: object, nullable: true, additionalProperties: true } + has_deep_access: { type: boolean } + created_at: { type: string, format: date-time } + + MembershipMe: + type: object + required: [active, status] + properties: + active: { type: boolean } + plan: { type: string } + status: { type: string } + expires_at: { type: string, format: date-time, nullable: true } + ask_quota_left: { type: integer } + + ScaleSummary: + type: object + required: [slug, title, description] + properties: + slug: { type: string } + title: { type: string } + description: { type: string } + + ScaleQuestionOption: + type: object + required: [key, text] + properties: + key: { type: string } + text: { type: string } + + ScaleQuestion: + type: object + required: [id, sort, body] + properties: + id: { type: string, format: uuid } + sort: { type: integer } + body: + type: object + required: [prompt, options] + properties: + prompt: { type: string } + options: + type: array + items: { $ref: '#/components/schemas/ScaleQuestionOption' } + + ScaleDetail: + type: object + required: [slug, title, description, questions] + properties: + slug: { type: string } + title: { type: string } + description: { type: string } + questions: + type: array + items: { $ref: '#/components/schemas/ScaleQuestion' } + + ScaleSubmitResult: + type: object + required: [id, result] + properties: + id: { type: string, format: uuid } + result: { type: object, additionalProperties: true } + + AskQuota: + type: object + required: [active_membership, remaining, free_limit, source] + properties: + active_membership: { type: boolean } + remaining: { type: integer } + free_limit: { type: integer } + source: { type: string } + + AskThread: + type: object + required: [id, user_id, profile_id, created_at] + properties: + id: { type: string, format: uuid } + user_id: { type: string, format: uuid } + profile_id: { type: string, format: uuid } + scene: { type: string, nullable: true } + created_at: { type: string, format: date-time } + + AskMessage: + type: object + required: [id, thread_id, role, content, created_at] + properties: + id: { type: string, format: uuid } + thread_id: { type: string, format: uuid } + role: { type: string, enum: [user, assistant] } + content: { type: string } + created_at: { type: string, format: date-time } + + ImageCardScene: + type: object + required: [key, label] + properties: + key: { type: string } + label: { type: string } + + ImageCardQuota: + type: object + required: [remaining, daily_free, unlimited] + properties: + remaining: { type: integer } + daily_free: { type: integer } + unlimited: { type: boolean } + + EnvelopeProfileList: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + type: object + properties: + items: + type: array + items: { $ref: '#/components/schemas/Profile' } + + EnvelopeGrowthReport: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: { $ref: '#/components/schemas/GrowthReport' } + + EnvelopeMembershipMe: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MembershipMe' } + + EnvelopeScaleList: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: + type: object + properties: + items: + type: array + items: { $ref: '#/components/schemas/ScaleSummary' } + + EnvelopeAskQuota: + allOf: + - $ref: '#/components/schemas/Envelope' + - type: object + properties: + data: { $ref: '#/components/schemas/AskQuota' }