Harden mobile smoke and contract ownership rules

This commit is contained in:
2026-05-27 09:48:35 +08:00
parent fcd9e9dbc2
commit 8d1615e1ed
6 changed files with 255 additions and 30 deletions
@@ -39,12 +39,15 @@ class GeneratedContractRepository:
tenant_id: str,
page: int,
page_size: int,
generated_by: Optional[str] = None,
contract_type: Optional[str] = None,
document_type_code: Optional[str] = None,
lifecycle_status: Optional[str] = None,
keyword: Optional[str] = None,
) -> List[GeneratedContract]:
query = self.db.query(GeneratedContract).filter(GeneratedContract.tenant_id == tenant_id)
if generated_by:
query = query.filter(GeneratedContract.generated_by == generated_by)
if contract_type:
query = query.filter(GeneratedContract.contract_type == contract_type)
if document_type_code:
@@ -67,12 +70,15 @@ class GeneratedContractRepository:
def count(
self,
tenant_id: str,
generated_by: Optional[str] = None,
contract_type: Optional[str] = None,
document_type_code: Optional[str] = None,
lifecycle_status: Optional[str] = None,
keyword: Optional[str] = None,
) -> int:
query = self.db.query(GeneratedContract).filter(GeneratedContract.tenant_id == tenant_id)
if generated_by:
query = query.filter(GeneratedContract.generated_by == generated_by)
if contract_type:
query = query.filter(GeneratedContract.contract_type == contract_type)
if document_type_code:
@@ -452,11 +452,13 @@ class ContractGenerationService:
keyword: Optional[str] = None,
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_by = None if current_user.get("role") == "admin" else str(current_user.get("id") or "")
normalized_lifecycle_status = self._normalize_optional_lifecycle_status(lifecycle_status)
items = self.generated_contract_repository.list_paginated(
tenant_id=tenant_id,
page=page,
page_size=page_size,
generated_by=generated_by,
contract_type=contract_type,
document_type_code=document_type_code,
lifecycle_status=normalized_lifecycle_status,
@@ -464,6 +466,7 @@ class ContractGenerationService:
)
total = self.generated_contract_repository.count(
tenant_id=tenant_id,
generated_by=generated_by,
contract_type=contract_type,
document_type_code=document_type_code,
lifecycle_status=normalized_lifecycle_status,
@@ -478,8 +481,7 @@ class ContractGenerationService:
def get_generated_contract(self, current_user: Dict[str, Any], contract_id: str) -> Dict:
tenant_id = current_user["tenant_id"]
generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
if not generated_contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在或无权访问")
return self._to_generated_contract_item(generated_contract)
@@ -497,7 +499,7 @@ class ContractGenerationService:
lifecycle_note: Optional[str] = None,
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
if not generated_contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
if current_user.get("role") != "admin" and generated_contract.generated_by != current_user.get("id"):
@@ -723,7 +725,7 @@ class ContractGenerationService:
def list_generated_contract_versions(self, current_user: Dict[str, Any], contract_id: str) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
if not generated_contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
versions = self.generated_contract_version_repository.list_by_contract(tenant_id, contract_id)
@@ -766,7 +768,7 @@ class ContractGenerationService:
def get_generated_contract_version(self, current_user: Dict[str, Any], contract_id: str, version_id: str) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
if not generated_contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
version = self.generated_contract_version_repository.get_by_id(tenant_id, contract_id, version_id)
@@ -833,11 +835,7 @@ class ContractGenerationService:
locate_status: str = "applied",
) -> Dict[str, Any]:
tenant_id = current_user["tenant_id"]
generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
if not generated_contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
if current_user.get("role") != "admin" and generated_contract.generated_by != current_user.get("id"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found")
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
normalized_content = str(content or "").strip()
if not normalized_content:
@@ -875,7 +873,7 @@ class ContractGenerationService:
def get_generated_contract_preview(self, current_user: Dict[str, Any], contract_id: str) -> Dict:
tenant_id = current_user["tenant_id"]
generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id)
generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id)
if not generated_contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同不存在或无权访问")
return {
+81 -3
View File
@@ -56,7 +56,15 @@ class StubContractGenerationService:
"chat_url": f"/chat?review={review_id}",
}
def _record(self, record_id, tenant_id="tenant-demo-001", contract_type="procurement", document_type_code="procurement_goods"):
def _record(
self,
record_id,
tenant_id="tenant-demo-001",
contract_type="procurement",
document_type_code="procurement_goods",
generated_by="user-admin",
generated_by_name="Admin",
):
return {
"id": record_id,
"template_id": "tpl-123",
@@ -90,8 +98,8 @@ class StubContractGenerationService:
"updated_at": None,
},
"metadata": {
"generated_by": "user-admin",
"generated_by_name": "Admin",
"generated_by": generated_by,
"generated_by_name": generated_by_name,
"tenant_id": tenant_id,
"original_template": "Procurement Template",
"document_type_code": document_type_code,
@@ -109,9 +117,21 @@ class StubContractGenerationService:
records = [
self._record("contract-123"),
self._record("contract-456", contract_type="employment", document_type_code="labor_contract"),
self._record(
"contract-member-owned",
tenant_id="tenant-demo-001",
generated_by="user-member",
generated_by_name="Member",
),
self._record("contract-other-tenant", tenant_id="tenant-other-001"),
]
tenant_records = [record for record in records if record["tenant_id"] == current_user["tenant_id"]]
if current_user.get("role") != "admin":
tenant_records = [
record
for record in tenant_records
if record["metadata"]["generated_by"] == current_user["id"]
]
if contract_type:
tenant_records = [record for record in tenant_records if record["contract_type"] == contract_type]
if document_type_code:
@@ -546,6 +566,14 @@ class ContractsApiTestCase(unittest.TestCase):
"role": "admin",
}
def _login_member(self, tenant_id="tenant-demo-001"):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-member",
"tenant_id": tenant_id,
"display_name": "Member",
"role": "member",
}
def test_user_can_list_contract_templates(self):
self._login()
response = self.client.get("/api/v1/contracts/templates", params={"contract_type": "procurement"})
@@ -561,6 +589,14 @@ class ContractsApiTestCase(unittest.TestCase):
self.assertEqual(body["data"]["items"][0]["document_type_code"], "labor_contract")
self.assertEqual(body["data"]["items"][0]["lifecycle"]["status"], "generated")
def test_member_generated_contract_list_only_returns_owned_records(self):
self._login_member()
response = self.client.get("/api/v1/contracts")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["data"]["total"], 1)
self.assertEqual(body["data"]["items"][0]["id"], "contract-member-owned")
def test_user_can_update_contract_lifecycle_and_write_audit_log(self):
self._login()
response = self.client.patch(
@@ -1326,6 +1362,48 @@ class ContractGenerationServiceTestCase(unittest.TestCase):
self.assertEqual(ctx.exception.status_code, 409)
def test_create_generated_contract_version_rejects_non_owner_member(self):
now = datetime.now(timezone.utc)
self.db.add(
self.GeneratedContract(
id="contract-editor-owner-only",
tenant_id="tenant-demo-001",
template_id="tpl-editor",
template_name="Editor Template",
contract_type="procurement",
document_type_code="procurement_goods",
generated_by="user-admin",
generated_by_name="Admin",
export_format="docx",
file_id=None,
file_name="editor-owner-only.docx",
mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
file_size=10,
generated_content="Original contract content.",
variable_values_json={},
export_options_json={},
file_content_base64="",
status="generated",
created_at=now,
)
)
self.db.commit()
with self.assertRaises(HTTPException) as ctx:
self.service.create_generated_contract_version(
current_user={
"id": "user-member",
"tenant_id": "tenant-demo-001",
"display_name": "Member",
"role": "member",
},
contract_id="contract-editor-owner-only",
content="Original contract content.\nAcceptance clause added.",
change_summary="member edit attempt",
)
self.assertEqual(ctx.exception.status_code, 404)
def test_diff_generated_contract_versions_returns_paragraph_changes(self):
now = datetime.now(timezone.utc)
self.db.add(
@@ -860,7 +860,31 @@ class _GeneratedContractVersionEditorScreenState
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('编辑合同版本')),
appBar: AppBar(
title: const Text('编辑合同版本'),
actions: [
ValueListenableBuilder<bool>(
valueListenable: widget.controller.isSaving,
builder: (context, isSaving, _) {
return Semantics(
label: 'save-contract-version-appbar',
button: true,
child: IconButton(
onPressed: isSaving ? null : _save,
tooltip: '保存新版本',
icon: isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
),
);
},
),
],
),
bottomNavigationBar: SafeArea(
top: false,
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
@@ -180,6 +180,8 @@ void main() {
findsOneWidget);
expect(find.bySemanticsLabel('contract-version-content-input'),
findsOneWidget);
expect(find.bySemanticsLabel('save-contract-version-appbar'),
findsOneWidget);
final summaryField = find.descendant(
of: find.bySemanticsLabel('contract-version-change-summary-input'),
+132 -15
View File
@@ -90,7 +90,15 @@ function Wait-ForAdbDeviceOnline {
$repairAttempted = $false
for ($attempt = 1; $attempt -le $RetryCount; $attempt++) {
$state = ((& $script:adb -s $Serial get-state 2>&1) -join [Environment]::NewLine).Trim()
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$state = ((& $script:adb -s $Serial get-state 2>&1) -join [Environment]::NewLine).Trim()
} catch {
$state = ($_.Exception.Message -replace '\s+', ' ').Trim()
} finally {
$ErrorActionPreference = $previousErrorActionPreference
}
if ($state -eq "device") {
return $true
}
@@ -140,6 +148,33 @@ function Get-AppSurface {
return "unknown"
}
function Test-ContractVersionListContainsMarker {
param(
[string]$UiDump,
[string]$Marker
)
if ([string]::IsNullOrWhiteSpace($Marker)) {
return $false
}
if ($UiDump -match 'contract-version-editor') {
return $false
}
if ($UiDump -notmatch 'contract-version-chain') {
return $false
}
$normalized = $UiDump.ToLowerInvariant()
$parts = $Marker.ToLowerInvariant().Split('_') | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($part in $parts) {
if ($normalized -notmatch [regex]::Escape($part)) {
return $false
}
}
return $true
}
function Get-ApkPath {
$candidate = Join-Path $PSScriptRoot "..\build\app\outputs\flutter-apk\app-debug.apk"
$resolved = Resolve-Path $candidate -ErrorAction SilentlyContinue
@@ -271,6 +306,47 @@ function Get-ContentDescBounds {
return $results
}
function Get-NestedButtonBounds {
param([string]$UiDump)
$matches = [regex]::Matches(
$UiDump,
'class="android\.widget\.Button".*?content-desc="([^"]*)".*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]".*?<node index="\d+" text="" resource-id="" class="android\.widget\.Button".*?content-desc="([^"]*)".*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"'
)
$results = @()
foreach ($match in $matches) {
$results += [pscustomobject]@{
OuterContentDesc = $match.Groups[1].Value
InnerContentDesc = $match.Groups[6].Value
Left = [int]$match.Groups[7].Value
Top = [int]$match.Groups[8].Value
Right = [int]$match.Groups[9].Value
Bottom = [int]$match.Groups[10].Value
}
}
return $results
}
function Get-InnerButtonBounds {
param([string]$UiDump)
$matches = [regex]::Matches(
$UiDump,
'class="android\.widget\.Button".*?content-desc="([^"]*)".*?bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"'
)
$results = @()
foreach ($match in $matches) {
$results += [pscustomobject]@{
ContentDesc = $match.Groups[1].Value
Left = [int]$match.Groups[2].Value
Top = [int]$match.Groups[3].Value
Right = [int]$match.Groups[4].Value
Bottom = [int]$match.Groups[5].Value
}
}
return $results
}
function Get-TextBounds {
param([string]$UiDump)
@@ -803,6 +879,24 @@ function Invoke-ContractVersionChainProof {
Start-Sleep -Seconds 3
$workspaceState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-workspace" -ArtifactDir $ArtifactDir -PackageName $PackageName
if ($workspaceState.UiDump -match '0 个模板' -or $workspaceState.UiDump -match '0 条历史' -or
$workspaceState.UiDump -match '获取合同生成历史失败' -or $workspaceState.UiDump -match '暂无历史结果') {
$refreshButton = Get-ContentDescBounds -UiDump $workspaceState.UiDump |
Where-Object { $_.ContentDesc -eq "刷新" } |
Select-Object -First 1
if (-not $refreshButton) {
$refreshButton = Get-InnerButtonBounds -UiDump $workspaceState.UiDump |
Where-Object { $_.ContentDesc -eq "刷新" } |
Select-Object -First 1
}
if ($refreshButton) {
$refreshCenter = Get-BoundsCenter -Bounds $refreshButton
Invoke-AdbTap -Serial $Serial -X $refreshCenter.X -Y $refreshCenter.Y
Start-Sleep -Seconds 4
$workspaceState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-workspace-refresh" -ArtifactDir $ArtifactDir -PackageName $PackageName
}
}
$historyTab = Get-ContentDescBounds -UiDump $workspaceState.UiDump |
Where-Object { $_.ContentDesc -match '^contract-history-tab' } |
Select-Object -First 1
@@ -958,12 +1052,8 @@ function Invoke-ContractVersionChainProof {
Start-Sleep -Milliseconds 400
& $script:adb -s $Serial shell input keyevent KEYCODE_MOVE_END | Out-Null
Invoke-AdbTextEntry -Serial $Serial -Value " $saveMarker"
& $script:adb -s $Serial shell input keyevent KEYCODE_ENTER | Out-Null
Start-Sleep -Seconds 6
$afterEnterSaveState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-after-enter-save" -ArtifactDir $ArtifactDir -PackageName $PackageName
$saveOk = ($afterEnterSaveState.UiDump -match 'contract-version-chain') -and
-not ($afterEnterSaveState.UiDump -match 'contract-version-editor')
$createdVersionVisible = $afterEnterSaveState.UiDump -match [regex]::Escape($saveMarker)
& $script:adb -s $Serial shell input keyevent KEYCODE_BACK | Out-Null
Start-Sleep -Seconds 1
}
if (-not $saveOk) {
@@ -971,12 +1061,22 @@ function Invoke-ContractVersionChainProof {
$saveState = $editorState
for ($saveAttempt = 1; $saveAttempt -le 6; $saveAttempt++) {
$saveState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-save-button-$saveAttempt" -ArtifactDir $ArtifactDir -PackageName $PackageName
$saveButton = Get-ButtonBounds -UiDump $saveState.UiDump |
Where-Object { $_.ContentDesc -eq "保存新版本" } |
$saveButton = Get-NestedButtonBounds -UiDump $saveState.UiDump |
Where-Object { $_.InnerContentDesc -eq "保存新版本" -or $_.OuterContentDesc -eq "save-contract-version" } |
Select-Object -First 1
if (-not $saveButton) {
$saveButton = Get-ContentDescBounds -UiDump $saveState.UiDump |
Where-Object { $_.ContentDesc -eq "save-contract-version" } |
Where-Object { $_.ContentDesc -eq "save-contract-version-appbar" } |
Select-Object -First 1
}
if (-not $saveButton) {
$saveButton = Get-InnerButtonBounds -UiDump $saveState.UiDump |
Where-Object { $_.ContentDesc -eq "保存新版本" } |
Select-Object -First 1
}
if (-not $saveButton) {
$saveButton = Get-ContentDescBounds -UiDump $saveState.UiDump |
Where-Object { $_.ContentDesc -eq "保存新版本" -or $_.ContentDesc -eq "save-contract-version" } |
Select-Object -First 1
}
if (-not $saveButton) {
@@ -992,20 +1092,31 @@ function Invoke-ContractVersionChainProof {
}
if ($saveButton -and $editorOpened) {
$saveCenter = Get-SafeTapPoint -Bounds $saveButton
$saveCenter = if ($saveButton.Top -lt 320) {
Get-BoundsCenter -Bounds $saveButton
} else {
Get-BoundsCenter -Bounds $saveButton
}
Invoke-AdbTap -Serial $Serial -X $saveCenter.X -Y $saveCenter.Y
Start-Sleep -Milliseconds 800
if ($saveCenter.Y -gt 1960) {
Invoke-AdbTap -Serial $Serial -X $saveCenter.X -Y 1998
Start-Sleep -Milliseconds 800
}
Start-Sleep -Seconds 6
$afterSaveState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-after-save" -ArtifactDir $ArtifactDir -PackageName $PackageName
$saveOk = ($afterSaveState.UiDump -match 'contract-version-chain') -and
-not ($afterSaveState.UiDump -match 'contract-version-editor')
$createdVersionVisible = $afterSaveState.UiDump -match [regex]::Escape($saveMarker)
if (-not $createdVersionVisible) {
if ($saveOk) {
$createdVersionVisible = Test-ContractVersionListContainsMarker -UiDump $afterSaveState.UiDump -Marker $saveMarker
}
if ($saveOk -and -not $createdVersionVisible) {
for ($createdAttempt = 1; $createdAttempt -le 4; $createdAttempt++) {
Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1760 -EndX 540 -EndY 760 -DurationMs 320
Start-Sleep -Seconds 1
$createdState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-created-version-$createdAttempt" -ArtifactDir $ArtifactDir -PackageName $PackageName
if ($createdState.UiDump -match [regex]::Escape($saveMarker)) {
if (Test-ContractVersionListContainsMarker -UiDump $createdState.UiDump -Marker $saveMarker) {
$createdVersionVisible = $true
break
}
@@ -1446,7 +1557,7 @@ function Try-AutoLogin {
Start-Sleep -Milliseconds 800
$state = Get-SmokeState -Serial $Serial -Label "auto-login-before-submit" -ArtifactDir $ArtifactDir -PackageName $PackageName
$buttons = Get-ButtonBounds -UiDump $state.UiDump
$buttons = @(Get-ButtonBounds -UiDump $state.UiDump)
if ($buttons.Count -gt 0) {
$loginButton = $buttons | Sort-Object Bottom, Right | Select-Object -Last 1
$buttonCenter = Get-BoundsCenter -Bounds $loginButton
@@ -1593,7 +1704,13 @@ if (-not $SkipServerChecks) {
$apkPath = Get-ApkPath
$apkInstalled = $false
if (-not $SkipInstall -and $apkPath) {
if (-not (Wait-ForAdbDeviceOnline -Serial $serial -RetryCount 8 -DelaySeconds 2 -RepairOffline)) {
throw "Wireless ADB device '$serial' is not online before APK install."
}
& $adb -s $serial install -r $apkPath | Tee-Object -FilePath (Join-Path $artifactDir "install.txt") | Out-Null
if (-not (Wait-ForAdbDeviceOnline -Serial $serial -RetryCount 8 -DelaySeconds 2 -RepairOffline)) {
throw "Wireless ADB device '$serial' went offline after APK install."
}
$apkInstalled = $true
}