forked from baskbuddy/zhangshun
22 changed files with 580 additions and 30 deletions
@ -0,0 +1,150 @@ |
|||
"""重投 JAR 黑盒,仅固定本地测试容器/库。无生产目标参数,无真实短信。""" |
|||
import argparse,concurrent.futures,datetime,hashlib,json,os,pathlib,socket,subprocess,time,uuid |
|||
import requests,websocket |
|||
|
|||
ROOT=pathlib.Path('D:/Workspaces/qsp-reapply-20261020') |
|||
API='http://127.0.0.1:18090/api/v1' |
|||
DB='qsp_reapply_1020' |
|||
PASSWORD='qsp-reapply-isolated-only' |
|||
NAMES=['qsp-reapply-1020-mysql','qsp-reapply-1020-redis','qsp-reapply-1020-rabbit'] |
|||
RESULTS=[] |
|||
def save(name,data): |
|||
(ROOT/name).write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8') |
|||
def inspect(name):return json.loads(subprocess.check_output(['docker','inspect',name]))[0] |
|||
def sql(text): |
|||
i=inspect(NAMES[0]);assert i['Name']=='/'+NAMES[0] and i['HostConfig']['PortBindings']['3306/tcp'][0]['HostIp']=='127.0.0.1' |
|||
p=subprocess.run(['docker','exec','-i',NAMES[0],'mysql','-uroot','-p'+PASSWORD,'--default-character-set=utf8mb4','-NB',DB],input=text,capture_output=True,text=True,encoding='utf-8') |
|||
if p.returncode:raise RuntimeError(p.stderr[:400]) |
|||
return [line.split('\t') for line in p.stdout.strip().splitlines() if line] |
|||
def check(name,ok,detail=None): |
|||
RESULTS.append({'case':name,'ok':bool(ok),'detail':detail});save('blackbox-results.json',RESULTS);print(name,bool(ok),flush=True) |
|||
if not ok:raise AssertionError(name) |
|||
def http(method,path,token=None,body=None,files=None): |
|||
return requests.request(method,API+path,headers={'Authorization':'Bearer '+token} if token else {},json=body,files=files,timeout=30) |
|||
def good(name,method,path,token=None,body=None): |
|||
r=http(method,path,token,body);j=r.json();check(name,r.status_code==200,{'status':r.status_code,'response':j});return j['data'] |
|||
def denied(name,method,path,token,body,code): |
|||
expected={7001:404,8001:409,8303:403,8304:409,8309:409,8106:400,2002:403}[code] |
|||
r=http(method,path,token,body);j=r.json();check(name,r.status_code==expected and j.get('code')==code,{'expectedStatus':expected,'status':r.status_code,'response':j}) |
|||
def login(phone): |
|||
sql("INSERT INTO sms_verification_codes(phone,code,purpose,attempts,max_attempts,expired_at,used,created_at) VALUES('%s','246810','login',0,5,DATE_ADD(NOW(),INTERVAL 1 DAY),0,NOW())"%phone) |
|||
r=http('POST','/auth/login',body={'phone':phone,'code':'246810','purpose':'login'}) |
|||
if r.status_code!=200:raise RuntimeError('Isolated login failed') |
|||
return r.json()['data']['accessToken'] |
|||
def setup(): |
|||
for port in [3309,16390,15690,18090,18091]: |
|||
with socket.socket() as s: |
|||
if s.connect_ex(('127.0.0.1',port))==0:raise RuntimeError('Occupied port '+str(port)) |
|||
for name in NAMES: |
|||
if subprocess.run(['docker','inspect',name],capture_output=True).returncode==0:raise RuntimeError('Existing container: '+name) |
|||
state={'containers':{},'volumes':[]} |
|||
save('environment.json',state) |
|||
for name,image,port,internal,memory,extra in [ |
|||
(NAMES[0],'mysql:8.4',3309,3306,'768m',['-e','MYSQL_ROOT_PASSWORD='+PASSWORD,'-e','MYSQL_DATABASE='+DB]), |
|||
(NAMES[1],'redis:7.4-alpine',16390,6379,'128m',[]), |
|||
(NAMES[2],'rabbitmq:3.13-alpine',15690,5672,'256m',[])]: |
|||
cmd=['docker','run','-d','--name',name,'--label','qsp.acceptance=reapplication-20261020','--memory='+memory,'-p',f'127.0.0.1:{port}:{internal}']+extra+[image] |
|||
cid=subprocess.check_output(cmd,text=True).strip();state['containers'][name]=cid |
|||
state['volumes'].extend(m['Name'] for m in inspect(name)['Mounts'] if m['Type']=='volume');save('environment.json',state) |
|||
for _ in range(60): |
|||
try:sql('SELECT 1');return |
|||
except Exception:time.sleep(1) |
|||
raise RuntimeError('MySQL readiness timeout') |
|||
def start(): |
|||
jar=ROOT/'mydreamjob-server/target/mydreamjob-server-0.0.1-SNAPSHOT.jar' |
|||
for name in ['logs','tmp','storage','resume-exports','data-exports']:(ROOT/name).mkdir(exist_ok=True) |
|||
env=os.environ.copy();env.update(DB_URL=f'jdbc:mysql://127.0.0.1:3309/{DB}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai',DB_USERNAME='root',DB_PASSWORD=PASSWORD, |
|||
REDIS_HOST='127.0.0.1',REDIS_PORT='16390',REDIS_PASSWORD='',RABBITMQ_HOST='127.0.0.1',RABBITMQ_PORT='15690',RABBITMQ_USERNAME='guest',RABBITMQ_PASSWORD='guest', |
|||
JWT_ACCESS_SECRET='reapply-isolated-access-secret-no-production-0001',JWT_REFRESH_SECRET='reapply-isolated-refresh-secret-no-production-001',JWT_TOKEN_HASH_SECRET='reapply-isolated-tokenhash-secret-no-production-01', |
|||
SMS_ALIYUN_ENABLED='false',SMS_MOCK_ENABLED='true',ADMIN_BOOTSTRAP_ENABLED='false',RESUME_STORAGE_DIR=str(ROOT/'storage'),RESUME_EXPORT_DIR=str(ROOT/'resume-exports'),DATA_EXPORT_DIR=str(ROOT/'data-exports'),TEMP=str(ROOT/'tmp'),TMP=str(ROOT/'tmp')) |
|||
cmd=['java','-Xms128m','-Xmx512m','-Djava.io.tmpdir='+str(ROOT/'tmp'),'-jar',str(jar),'--server.address=127.0.0.1','--server.port=18090','--management.server.port=18091', |
|||
'--app.offer.scheduler.enabled=false','--app.match.notification.enabled=false','--app.notification.mq-enabled=false','--app.risk.scan.enabled=false','--app.scoring.refresh.enabled=false','--app.scoring.refresh.compensation-enabled=false','--spring.rabbitmq.listener.simple.auto-startup=false'] |
|||
p=subprocess.Popen(cmd,env=env,cwd=ROOT,stdout=open(ROOT/'logs/blackbox-app.log','ab'),stderr=open(ROOT/'logs/blackbox-app.err.log','ab'),creationflags=subprocess.CREATE_NO_WINDOW) |
|||
save('process.json',{'pid':p.pid,'jar':str(jar),'sha256':hashlib.sha256(jar.read_bytes()).hexdigest()}) |
|||
for _ in range(60): |
|||
if p.poll() is not None:raise RuntimeError('App exited') |
|||
try: |
|||
if requests.get('http://127.0.0.1:18091/actuator/health',timeout=2).status_code==200:print('APP_READY',p.pid);return |
|||
except requests.RequestException:pass |
|||
time.sleep(1) |
|||
raise RuntimeError('App health timeout') |
|||
def run(): |
|||
stamp=uuid.uuid4().hex[:8];suffix=uuid.uuid4().int%999999 |
|||
phones=['19908'+str(suffix+i).zfill(6) for i in range(2)] |
|||
for phone,role in zip(phones,['STUDENT','COMPANY']): |
|||
sql("INSERT INTO users(phone,nickname,role,is_active,is_verified) VALUES('%s','synthetic-%s','%s',1,1)"%(phone,stamp,role)) |
|||
sql("INSERT INTO user_roles(user_id,role) SELECT id,'%s' FROM users WHERE phone='%s'"%(role,phone)) |
|||
student=int(sql("SELECT id FROM users WHERE phone='%s'"%phones[0])[0][0]);hr=int(sql("SELECT id FROM users WHERE phone='%s'"%phones[1])[0][0]) |
|||
sql("INSERT INTO companies(owner_user_id,name,verified) VALUES(%d,'synthetic-reapply',1)"%hr) |
|||
ta,th=map(login,phones) |
|||
pdf=pathlib.Path('D:/Acceptance/qsp-filter-full-20261019-1/continuation-1/synthetic-resume.pdf').read_bytes() |
|||
r=http('POST','/profile/me/resume',ta,files={'file':('synthetic.pdf',pdf,'application/pdf')});check('real PDF upload',r.status_code==200) |
|||
good('privacy','PUT','/profile/me/privacy',ta,{'resumeVisibility':'APPLIED_COMPANIES','contactVisible':False}) |
|||
job=good('create job','POST','/jobs',th,{'title':'synthetic-reapply','description':'not real recruitment','city':'TEST','employmentType':'FULL_TIME'})['id'] |
|||
good('publish job','PATCH',f'/jobs/{job}/status',th,{'status':'PUBLISHED'}) |
|||
def apply():return http('POST','/applications',ta,{'jobId':job}) |
|||
def match(app): |
|||
good('candidate scores new round','PUT',f'/applications/{app}/candidate-interest-score',ta,{'score':90}) |
|||
d=good('HR scores new round','PUT',f'/applications/{app}/hr-suitability-score',th,{'score':90});check('new round matched',d['status']=='MATCHED') |
|||
return good('matched conversation','POST',f'/applications/{app}/conversation',ta)['id'] |
|||
a=apply().json()['data'];check('first round',a['attemptNumber']==1) |
|||
conv=match(a['id']);oldmsg=good('old resume card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':a['id'],'clientMessageId':'old-'+stamp})['id'] |
|||
future=datetime.datetime.now()+datetime.timedelta(days=2) |
|||
interview=good('old interview fixture','POST',f"/applications/{a['id']}/interviews",th,{'title':'synthetic old interview','interviewType':'ONLINE','startAt':future.isoformat(),'endAt':(future+datetime.timedelta(hours=1)).isoformat(),'meetingUrl':'https://example.invalid/interview'}) |
|||
offer=good('old draft offer fixture','POST','/offers',th,{'applicationId':a['id'],'title':'synthetic old offer','description':'not real employment','workCity':'TEST','expireAt':future.isoformat()}) |
|||
oldurl=f'/conversations/{conv}/messages/{oldmsg}/resume' |
|||
good('withdraw first round','POST',f"/applications/{a['id']}/withdraw",ta) |
|||
denied('old card denied after withdrawal','GET',oldurl,th,None,8309) |
|||
denied('old company download denied','GET',f"/company/candidates/{a['id']}/resume",th,None,2002) |
|||
denied('old application cannot unlock','POST',f"/applications/{a['id']}/conversation",ta,None,8304) |
|||
denied('historical send locked','POST','/conversations/messages',ta,{'conversationId':conv,'content':'blocked'},8303) |
|||
good('history readable','GET',f'/conversations/{conv}/messages',ta) |
|||
b=apply().json()['data'];check('second id and lineage',b['id']!=a['id'] and b['attemptNumber']==2 and b['previousApplicationId']==a['id'],b) |
|||
d=good('new round interview list','GET',f"/applications/{b['id']}/interviews",th);check('interviews not copied',d==[],d) |
|||
d=good('new round offer list','GET',f"/offers?applicationId={b['id']}",th);check('offers not copied',d['total']==0,d) |
|||
denied('old offer cannot be sent after withdrawal','PATCH',f"/offers/{offer['id']}/status",th,{'status':'SENT'},8106) |
|||
denied('new round not auto-matched','POST',f"/applications/{b['id']}/conversation",ta,None,8304) |
|||
denied('new round cannot send inherited card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':b['id'],'clientMessageId':'pending-'+stamp},8303) |
|||
good('withdraw second round','POST',f"/applications/{b['id']}/withdraw",ta) |
|||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:responses=list(pool.map(lambda _:apply(),range(4))) |
|||
wins=[r for r in responses if r.status_code==200];losers=[r for r in responses if r.status_code!=200] |
|||
check('concurrent one winner three business duplicates',len(wins)==1 and len(losers)==3 and all(r.json().get('code')==8001 for r in losers),[{'status':r.status_code,'data':r.json()} for r in responses]) |
|||
c=wins[0].json()['data'];check('third round lineage',c['attemptNumber']==3 and c['previousApplicationId']==b['id'],c) |
|||
newpdf=pdf+b'\n% new-round-current-resume\n';r=http('POST','/profile/me/resume',ta,files={'file':('current.pdf',newpdf,'application/pdf')});check('replace source before rematch',r.status_code==200) |
|||
check('rematch reuses historical chat',match(c['id'])==conv) |
|||
ws=websocket.create_connection('ws://127.0.0.1:18090/api/v1/ws',origin='http://localhost:3000',subprotocols=['v12.stomp'],timeout=10,http_proxy_host=None) |
|||
ws.send('CONNECT\naccept-version:1.2\nheart-beat:0,0\nAuthorization:Bearer '+th+'\n\n\x00');check('STOMP after rematch authenticated',ws.recv().startswith('CONNECTED')) |
|||
ws.send('SUBSCRIBE\nid:reapply\ndestination:/user/queue/chat-events\n\n\x00');check('STOMP subscription ready','CHAT_READY' in ws.recv()) |
|||
text=good('send after fresh match','POST','/conversations/messages',ta,{'conversationId':conv,'content':'new round','clientMessageId':'new-'+stamp}) |
|||
frame=ws.recv();event=json.loads(frame.split('\n\n',1)[1].rstrip('\x00'));check('new round websocket exact message',event.get('type')=='MESSAGE_CREATED' and event.get('payload',{}).get('id')==text['id'],event);ws.close() |
|||
denied('old card remains denied despite new match','GET',oldurl,th,None,8309) |
|||
newmsg=good('new round resume card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':c['id'],'clientMessageId':'resume-'+stamp})['id'] |
|||
newurl=f'/conversations/{conv}/messages/{newmsg}/resume';r=http('GET',newurl,th);check('new card uses current PDF',r.status_code==200 and r.content==newpdf) |
|||
timeline=good('old timeline retained','GET',f"/applications/{a['id']}/timeline",ta);check('old withdrawal in timeline',any(x['toStatus']=='WITHDRAWN' for x in timeline)) |
|||
page=good('student list','GET','/applications/me',ta);items=[x for x in page['items'] if x['jobId']==job];check('three rounds listed',len(items)==3 and {x['attemptNumber'] for x in items}=={1,2,3}) |
|||
d=good('company candidate detail','GET',f"/company/candidates/{c['id']}",th);check('company detail lineage',d['attemptNumber']==3 and d['previousApplicationId']==b['id'],d) |
|||
d=good('company candidate list','GET',f'/company/candidates?jobId={job}',th);check('company list preserves all rounds',len(d['items'])==3 and {x['attemptNumber'] for x in d['items']}=={1,2,3},d) |
|||
closed=good('closed fixture job','POST','/jobs',th,{'title':'closed fixture','description':'synthetic','city':'TEST','employmentType':'FULL_TIME'})['id'] |
|||
good('publish closed fixture','PATCH',f'/jobs/{closed}/status',th,{'status':'PUBLISHED'}) |
|||
app=good('apply closed fixture','POST','/applications',ta,{'jobId':closed})['id'];good('withdraw closed fixture','POST',f'/applications/{app}/withdraw',ta) |
|||
good('close fixture job','PATCH',f'/jobs/{closed}/status',th,{'status':'CLOSED'}) |
|||
denied('closed job blocks reapply','POST','/applications',ta,{'jobId':closed},7001) |
|||
rejected=good('rejected fixture job','POST','/jobs',th,{'title':'rejected fixture','description':'synthetic','city':'TEST','employmentType':'FULL_TIME'})['id'] |
|||
good('publish rejected fixture','PATCH',f'/jobs/{rejected}/status',th,{'status':'PUBLISHED'}) |
|||
app=good('apply rejected fixture','POST','/applications',ta,{'jobId':rejected})['id'];good('reject fixture application','PATCH',f'/applications/{app}/status',th,{'status':'REJECTED'}) |
|||
denied('rejected still blocks reapply','POST','/applications',ta,{'jobId':rejected},8001) |
|||
save('blackbox-state.json',{'phones':phones,'job':job,'oldApplication':a['id'],'currentApplication':c['id'],'conversation':conv,'oldUrl':oldurl,'newUrl':newurl,'sha256':hashlib.sha256(newpdf).hexdigest()}) |
|||
def recheck(): |
|||
s=json.loads((ROOT/'blackbox-state.json').read_text(encoding='utf-8'));ta,th=map(login,s['phones']) |
|||
d=good('current application survives restart','GET',f"/applications/{s['currentApplication']}",ta);check('third round persists',d['attemptNumber']==3 and d['status']=='SUBMITTED') |
|||
d=good('old application survives restart','GET',f"/applications/{s['oldApplication']}",ta);check('withdrawn state persists',d['status']=='WITHDRAWN') |
|||
denied('old card still denied after restart','GET',s['oldUrl'],th,None,8309) |
|||
r=http('GET',s['newUrl'],th);check('new authorized bytes persist',r.status_code==200 and hashlib.sha256(r.content).hexdigest()==s['sha256']) |
|||
good('chat sending still unlocked by new match','POST','/conversations/messages',ta,{'conversationId':s['conversation'],'content':'restart','clientMessageId':'restart-'+uuid.uuid4().hex}) |
|||
if __name__=='__main__': |
|||
parser=argparse.ArgumentParser();parser.add_argument('mode',choices=['setup','start','run','recheck']);a=parser.parse_args() |
|||
ROOT.mkdir(parents=True,exist_ok=True) |
|||
if a.mode in ['run','recheck']: |
|||
try:globals()[a.mode]() |
|||
finally:save('blackbox-'+a.mode+'-results.json',RESULTS) |
|||
else:globals()[a.mode]() |
|||
@ -0,0 +1,25 @@ |
|||
"""本批静态校验及当前隔离 JAR 的契约对照,不修改契约。""" |
|||
import json,pathlib,subprocess,sys,requests |
|||
ROOT=pathlib.Path('D:/Workspaces/qsp-reapply-20261020') |
|||
REPO=pathlib.Path(__file__).resolve().parents[3] |
|||
rows=[] |
|||
def check(name,ok,actual=None):rows.append({'case':name,'ok':bool(ok),'actual':actual}) |
|||
p=subprocess.run([sys.executable,str(REPO/'docs/verify_openapi.py')],capture_output=True,text=True,encoding='utf-8',errors='replace',env={**__import__('os').environ,'PYTHONIOENCODING':'utf-8'}) |
|||
(ROOT/'logs/static-openapi.log').write_text(p.stdout+p.stderr,encoding='utf-8');check('static verifier',p.returncode==0,p.returncode) |
|||
static=json.loads((REPO/'docs/openapi-rc.json').read_text(encoding='utf-8')) |
|||
r=requests.get('http://127.0.0.1:18090/api/v1/v3/api-docs',timeout=30);check('runtime OpenAPI reachable',r.status_code==200,r.status_code);runtime=r.json() |
|||
(ROOT/'runtime-openapi.json').write_text(json.dumps(runtime,ensure_ascii=False),encoding='utf-8') |
|||
check('same path identities',set(static['paths'])==set(runtime['paths'])) |
|||
methods={'get','post','put','patch','delete','head','options','trace'} |
|||
ops=lambda doc:{(p,m) for p,vs in doc['paths'].items() for m in vs if m in methods} |
|||
check('same operation identities',ops(static)==ops(runtime)) |
|||
for name in ['CandidateItemView','CandidateDetailView']: |
|||
for field,fmt in [('attemptNumber','int32'),('previousApplicationId','int64')]: |
|||
a=static['components']['schemas'][name]['properties'][field];b=runtime['components']['schemas'][name]['properties'][field] |
|||
check(name+'.'+field,a.get('type')==b.get('type')=='integer' and a.get('format')==b.get('format')==fmt,{'static':a,'runtime':b}) |
|||
forbidden=set(runtime['components']['schemas'][name]['properties'])&{'studentUserId','phone','email','resumeUrl','storagePath'} |
|||
check(name+' remains redacted',not forbidden,sorted(forbidden)) |
|||
for path in ['/applications','/applications/{id}/withdraw']: |
|||
check(path+' description',static['paths'][path]['post']['description']==runtime['paths'][path]['post']['description']) |
|||
(ROOT/'contract-results.json').write_text(json.dumps(rows,ensure_ascii=False,indent=2),encoding='utf-8') |
|||
print('CONTRACT',len(rows),'FAIL',sum(not r['ok'] for r in rows));raise SystemExit(any(not r['ok'] for r in rows)) |
|||
@ -0,0 +1,67 @@ |
|||
# 撤回后重新投递:后端与前端联调约定 |
|||
|
|||
范围:仅后端重投规则、兼容字段和关联权限;不调整已拒绝后的重投政策,不修改前端,不删除历史数据。测试结果以本批最终验收记录为准;本说明不代表已经上线。 |
|||
|
|||
## 1. 重投规则 |
|||
|
|||
| 当前情况 | POST /applications | |
|||
| --- | --- | |
|||
| 没有投递历史 | 创建首轮,SUBMITTED | |
|||
| 同岗位所有历史申请均为 WITHDRAWN | 创建新 id、新轮次,SUBMITTED | |
|||
| 存在 SUBMITTED/VIEWED/INTERVIEW | 拒绝重复投递 | |
|||
| 存在 REJECTED/HIRED | 仍拒绝,本批不放开 | |
|||
| 岗位不是 PUBLISHED | 不允许投递,包括撤回后重投 | |
|||
|
|||
撤回仍只允许 SUBMITTED/VIEWED,不放开面试中撤回。旧记录不恢复为 SUBMITTED;每轮有独立时间线,旧匹配、面试、Offer 及简历卡片继续关联旧 id。 |
|||
|
|||
V58 新增 attempt_number、previous_application_id 和按状态生成的 active_application_key。旧记录轮次为 1;仅 WITHDRAWN 的占位键为 NULL。数据库同时限制“同岗位同求职者同轮次唯一”和“同岗位同求职者非撤回申请唯一”。投递事务锁定岗位,检查其发布状态、占位及上一轮,防止并发重投。岗位级锁会串行处理同岗位投递,是本批明确的正确性取舍。 |
|||
|
|||
## 2. 接口字段与错误码 |
|||
|
|||
继续调用 `POST /api/v1/applications`,请求不变: |
|||
|
|||
```json |
|||
{"jobId":123} |
|||
``` |
|||
|
|||
重投成功仍返回 HTTP 200,新响应 `data` 示例(其他既有字段保留): |
|||
|
|||
```json |
|||
{"id":456,"jobId":123,"status":"SUBMITTED","attemptNumber":2,"previousApplicationId":321} |
|||
``` |
|||
|
|||
- `id`:本轮投递 ID。后续评分、企业处理、面试、Offer、会话解锁及简历卡片操作必须使用对应轮次的 ID。 |
|||
- `attemptNumber`:从 1 开始;用于区分同岗位不同轮次,不代表新的投递状态。 |
|||
- `previousApplicationId`:直接上一轮 id;首轮为 null,受全局非空序列化影响也可能省略。 |
|||
- 上述字段下发于本人投递列表/详情、企业投递列表、企业候选人列表/详情。候选人 DTO 不新增手机号、用户 ID、简历存储引用等敏感字段。 |
|||
- 重复投递沿用 HTTP 409 / `code=8001`,岗位不可投递沿用 HTTP 404 / `code=7001`。不要把所有非 200 都显示为“网络错误”。 |
|||
- `code=8003`:撤回/状态流转不允许;`8304`:指定轮次未匹配;`8303`:企业与求职者当前没有有效匹配,不能建立/恢复发送。 |
|||
|
|||
前端撤回后将该轮显示为“已撤回”,在岗位仍可投递时展示“重新投递”。成功后使用服务端返回的新 id 刷新列表,不覆盖旧记录。防连点只用于体验,后端仍负责并发约束;请求超时后先刷新投递记录,不能擅自推断成功。 |
|||
|
|||
同岗位多轮全部保留在列表;判断最新轮次用该岗位的最大 attemptNumber,不依赖秒级 createdAt 相同情况下的数组顺序。 |
|||
|
|||
## 3. 旧会话与简历权限 |
|||
|
|||
- 新一轮不复制旧匹配分、阈值快照或匹配结果。旧 id 即使保留 MATCHED 历史记录,只要投递已撤回,不能通过该 id 解锁会话。 |
|||
- 历史 DIRECT 会话可阅读。对有撤回历史的企业/求职者组合,发送消息和通过通用接口获取旧会话,必须存在双方当前有效 MATCHED 申请。重新匹配后可以复用原会话,不复制历史消息。 |
|||
- 沟通解锁仍采用既有“企业—求职者关系”口径:若双方另一个岗位存在有效匹配,可以继续沟通;不能把它描述为每个岗位拥有完全独立的聊天权限。 |
|||
- 聊天简历快照继续绑定原 applicationId,撤回后不能因新一轮匹配而恢复旧卡片授权;已显式撤销的卡片不复活。 |
|||
- 本批不引入“投递时固化简历”新机制。企业当前简历下载仍读取求职者当前资料,并依据指定 applicationId、企业归属与隐私策略授权;主动发送卡片时才保存不可变快照。 |
|||
- PRIVATE 为动态限制;VERIFIED_COMPANIES 等既有显式隐私授权语义不在本批调整。不可将用户面向认证企业的全局授权误称为旧轮次权限恢复。 |
|||
- 面试和 Offer 不迁移到新 id,不恢复旧流程。 |
|||
|
|||
## 4. 前端联调验收步骤 |
|||
|
|||
1. 使用合成账号和已发布岗位首次投递,记录 id、轮次及列表状态。 |
|||
2. 企业查看后,求职者撤回,确认旧时间线仍在。 |
|||
3. 同一岗位重投,确认返回新 id、轮次加一、previousApplicationId 指向旧 id,企业收到新的待处理申请。 |
|||
4. 连续点击/并发请求,确认最多一条有效申请,其余返回重复投递错误。 |
|||
5. 对进行中、已拒绝、已录用和关闭岗位分别验证禁止重投。 |
|||
6. 旧轮次匹配不能解锁新轮次;历史聊天可读,新轮次重新满足匹配后才按关系级规则发送。 |
|||
7. 用真实结构的合成 PDF 验证旧卡片仍拒绝、新卡片绑定新轮次;更换源简历不能篡改旧快照。 |
|||
8. 重启隔离 JAR 后核对两轮记录、时间线和授权行为。 |
|||
|
|||
发布前还需:真实 MySQL 5.7/8 迁移、专项回归、最新 JAR 黑盒与后续全量验证。前端接入与提交/推送/发布均另行安排。 |
|||
|
|||
本批本地验证记录(2026-10-20):专项 Maven 测试合计 129 项,0 失败/错误/跳过;新 JAR 黑盒 61 项及重启复验 7 项通过;静态与运行时契约专项 12 项通过。已覆盖真实 MySQL 5.7/8 的 V57→V58 升级及 MySQL 并发。证据与制品在 `D:\Workspaces\qsp-reapply-20261020`。本批完整 `mvn verify` 尚未执行;上述结果不代表线上已经更新。 |
|||
@ -0,0 +1,12 @@ |
|||
-- 撤回保留历史,新一轮使用新的 application id。仅 WITHDRAWN 释放占位。 |
|||
ALTER TABLE applications ADD COLUMN attempt_number INT NOT NULL DEFAULT 1; |
|||
ALTER TABLE applications ADD COLUMN previous_application_id BIGINT NULL; |
|||
-- MySQL 5.7/8 默认 VIRTUAL;省略 STORED,兼容 H2 MODE=MySQL 的真实迁移测试。 |
|||
ALTER TABLE applications ADD COLUMN active_application_key INT GENERATED ALWAYS AS |
|||
(CASE WHEN status = 'WITHDRAWN' THEN NULL ELSE 1 END); |
|||
ALTER TABLE applications ADD CONSTRAINT uk_applications_job_student_attempt |
|||
UNIQUE (job_id, student_user_id, attempt_number); |
|||
ALTER TABLE applications ADD CONSTRAINT uk_applications_job_student_active |
|||
UNIQUE (job_id, student_user_id, active_application_key); |
|||
-- 新索引已覆盖 job 外键所需的前缀,随后移除旧的永久唯一限制。 |
|||
ALTER TABLE applications DROP INDEX uk_applications_job_student; |
|||
@ -0,0 +1,3 @@ |
|||
package com.mydreamjob.application; |
|||
@org.springframework.test.context.ActiveProfiles("integration-test") |
|||
class ApplicationReapplicationIntegrationTest extends ApplicationReapplicationTestBase {} |
|||
@ -0,0 +1,55 @@ |
|||
package com.mydreamjob.application; |
|||
|
|||
import com.mydreamjob.config.MySql57MigrationConfiguration; |
|||
import org.flywaydb.core.Flyway; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.testcontainers.containers.MySQLContainer; |
|||
import java.sql.*; |
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
/** 真实 MySQL 5.7/8:V57 原数据升级、生成占位、唯一约束和旧关联保留。 */ |
|||
class ApplicationReapplicationMigrationTest { |
|||
@Test void mysql57Upgrade() throws Exception { verify("mysql:5.7.36"); } |
|||
@Test void mysql8Upgrade() throws Exception { verify("mysql:8.4"); } |
|||
Flyway flyway(String url,String user,String pass,String target) { |
|||
var config=Flyway.configure().dataSource(url,user,pass).locations("classpath:db/migration"); |
|||
if(target!=null) config.target(target); |
|||
MySql57MigrationConfiguration.configure(config); |
|||
return config.load(); |
|||
} |
|||
void verify(String image) throws Exception { |
|||
try(var mysql=new MySQLContainer<>(image).withDatabaseName("qsp_reapply_migration") |
|||
.withUsername("reapply").withPassword("isolated-only") |
|||
.withCreateContainerCmdModifier(cmd->cmd.getHostConfig().withMemory(768L*1024*1024))) { |
|||
mysql.start();String base=mysql.getJdbcUrl(); |
|||
String url=base+(base.contains("?")?"&":"?")+"characterEncoding=UTF-8"; |
|||
try(var c=DriverManager.getConnection(url,mysql.getUsername(),mysql.getPassword());var s=c.createStatement()) { |
|||
s.execute("ALTER DATABASE qsp_reapply_migration CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); |
|||
flyway(url,mysql.getUsername(),mysql.getPassword(),"57").migrate(); |
|||
s.execute("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(990001,'19908000001','COMPANY',1,1),(990002,'19908000002','STUDENT',1,1)"); |
|||
s.execute("INSERT INTO companies(id,owner_user_id,name,verified) VALUES(990001,990001,'synthetic migration',1)"); |
|||
s.execute("INSERT INTO jobs(id,company_id,title,description,city,employment_type,status) VALUES(990001,990001,'synthetic','test','test','FULL_TIME','PUBLISHED'),(990002,990001,'blocked','test','test','FULL_TIME','PUBLISHED')"); |
|||
s.execute("INSERT INTO applications(id,job_id,student_user_id,status) VALUES(990001,990001,990002,'WITHDRAWN'),(990002,990002,990002,'REJECTED')"); |
|||
s.execute("INSERT INTO application_status_history(application_id,operator_user_id,from_status,to_status) VALUES(990001,990002,'SUBMITTED','WITHDRAWN')"); |
|||
s.execute("INSERT INTO application_match_records(application_id,job_id,company_id,candidate_id,status) VALUES(990001,990001,990001,990002,'MATCHED')"); |
|||
assertEquals(1,flyway(url,mysql.getUsername(),mysql.getPassword(),null).migrate().migrationsExecuted); |
|||
assertEquals("WITHDRAWN",scalar(s,"SELECT status FROM applications WHERE id=990001")); |
|||
assertEquals("1",scalar(s,"SELECT attempt_number FROM applications WHERE id=990001")); |
|||
assertNull(scalar(s,"SELECT active_application_key FROM applications WHERE id=990001")); |
|||
assertEquals("1",scalar(s,"SELECT active_application_key FROM applications WHERE id=990002")); |
|||
assertEquals("MATCHED",scalar(s,"SELECT status FROM application_match_records WHERE application_id=990001")); |
|||
assertEquals("1",scalar(s,"SELECT COUNT(*) FROM application_status_history WHERE application_id=990001")); |
|||
s.execute("INSERT INTO applications(id,job_id,student_user_id,status,attempt_number,previous_application_id) VALUES(990003,990001,990002,'SUBMITTED',2,990001)"); |
|||
assertThrows(SQLException.class,()->s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number) VALUES(990001,990002,'SUBMITTED',3)")); |
|||
assertThrows(SQLException.class,()->s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number) VALUES(990002,990002,'SUBMITTED',2)")); |
|||
s.execute("UPDATE applications SET status='WITHDRAWN' WHERE id=990003"); |
|||
s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number,previous_application_id) VALUES(990001,990002,'SUBMITTED',3,990003)"); |
|||
assertThrows(SQLException.class,()->s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number) VALUES(990001,990002,'WITHDRAWN',2)")); |
|||
assertEquals("3",scalar(s,"SELECT COUNT(*) FROM applications WHERE job_id=990001")); |
|||
flyway(url,mysql.getUsername(),mysql.getPassword(),null).validate(); |
|||
assertEquals(0,flyway(url,mysql.getUsername(),mysql.getPassword(),null).migrate().migrationsExecuted); |
|||
} |
|||
} |
|||
} |
|||
String scalar(Statement s,String sql) throws SQLException {try(var r=s.executeQuery(sql)){assertTrue(r.next());return r.getString(1);}} |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
package com.mydreamjob.application; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.test.context.*; |
|||
import org.springframework.transaction.annotation.*; |
|||
import org.testcontainers.containers.MySQLContainer; |
|||
import org.testcontainers.junit.jupiter.*; |
|||
import java.util.concurrent.*; |
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
@ActiveProfiles("mysql-it") @Testcontainers(disabledWithoutDocker=false) |
|||
@org.springframework.test.annotation.DirtiesContext(classMode=org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) |
|||
class ApplicationReapplicationMySqlIntegrationTest extends ApplicationReapplicationTestBase { |
|||
@Container static final MySQLContainer<?> MYSQL=new MySQLContainer<>("mysql:8.4") |
|||
.withDatabaseName("qsp_reapply_test").withUsername("reapply").withPassword("isolated-test-only"); |
|||
@DynamicPropertySource static void properties(DynamicPropertyRegistry r) { |
|||
r.add("spring.datasource.url",MYSQL::getJdbcUrl);r.add("spring.datasource.username",MYSQL::getUsername);r.add("spring.datasource.password",MYSQL::getPassword); |
|||
} |
|||
@Test @Transactional(propagation=Propagation.NOT_SUPPORTED) |
|||
void concurrentReapplicationHasExactlyOneWinner() throws Exception { |
|||
var original=apply();service.withdraw(student.getId(),original.getId()); |
|||
var ready=new CountDownLatch(4);var start=new CountDownLatch(1);var pool=Executors.newFixedThreadPool(4); |
|||
try { |
|||
var futures=new java.util.ArrayList<Future<Boolean>>(); |
|||
for(int i=0;i<4;i++) futures.add(pool.submit(()->{ready.countDown();assertTrue(start.await(10,TimeUnit.SECONDS)); |
|||
try {apply();return true;} catch(com.mydreamjob.common.exception.BusinessException e) { |
|||
assertEquals(com.mydreamjob.common.exception.ErrorCode.APPLICATION_DUPLICATE.getCode(),e.getErrorCodeInt());return false;}})); |
|||
assertTrue(ready.await(10,TimeUnit.SECONDS));start.countDown();int winners=0; |
|||
for(var f:futures) if(f.get(30,TimeUnit.SECONDS)) winners++; |
|||
assertEquals(1,winners); |
|||
var all=applications.findByJobIdOrderByCreatedAtDesc(job.getId());assertEquals(2,all.size()); |
|||
assertEquals(1,all.stream().filter(x->"SUBMITTED".equals(x.getStatus())).count()); |
|||
} finally {start.countDown();pool.shutdownNow();} |
|||
} |
|||
} |
|||
@ -0,0 +1,111 @@ |
|||
package com.mydreamjob.application; |
|||
|
|||
import com.mydreamjob.auth.*; |
|||
import com.mydreamjob.company.*; |
|||
import com.mydreamjob.job.*; |
|||
import com.mydreamjob.application.dto.ApplicationCreateRequest; |
|||
import com.mydreamjob.common.exception.*; |
|||
import com.mydreamjob.bidirectionalmatch.*; |
|||
import com.mydreamjob.messaging.*; |
|||
import com.mydreamjob.messaging.dto.*; |
|||
import jakarta.persistence.EntityManager; |
|||
import org.junit.jupiter.api.*; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
import java.util.*; |
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
|||
|
|||
@SpringBootTest @AutoConfigureMockMvc @Transactional |
|||
abstract class ApplicationReapplicationTestBase { |
|||
@Autowired ApplicationService service; |
|||
@Autowired ApplicationRepository applications; |
|||
@Autowired ApplicationStatusHistoryRepository history; |
|||
@Autowired UserRepository users; |
|||
@Autowired CompanyRepository companies; |
|||
@Autowired JobRepository jobs; |
|||
@Autowired EntityManager em; |
|||
@Autowired ApplicationMatchRecordRepository matches; |
|||
@Autowired MatchConversationService matchConversations; |
|||
@Autowired MessagingService messaging; |
|||
@Autowired ConversationRepository conversations; |
|||
@Autowired ConversationMemberRepository members; |
|||
@Autowired AuthSessionService sessions; |
|||
@Autowired java.time.Clock clock; |
|||
@Autowired MockMvc mvc; |
|||
@MockBean RedisTemplate<String,Object> redis; |
|||
User student, hr; Company company; Job job; |
|||
@BeforeEach void fixture() { |
|||
student=user(User.Role.STUDENT);hr=user(User.Role.COMPANY); |
|||
company=companies.saveAndFlush(Company.builder().ownerUserId(hr.getId()).name("重投隔离测试").verified(true).build()); |
|||
job=jobs.saveAndFlush(Job.builder().companyId(company.getId()).title("重投测试岗位").description("合成数据") |
|||
.status("PUBLISHED").employmentType("FULL_TIME").city("测试").build()); |
|||
} |
|||
User user(User.Role role) { |
|||
return users.saveAndFlush(User.builder().phone("19"+String.format("%09d",Math.abs(UUID.randomUUID().getMostSignificantBits()%1000000000))) |
|||
.role(role).roles(Set.of(role)).active(true).verified(true).build()); |
|||
} |
|||
Application apply() {var req=new ApplicationCreateRequest();req.setJobId(job.getId());return service.apply(student.getId(),req);} |
|||
void error(ErrorCode code,org.junit.jupiter.api.function.Executable f) { |
|||
assertEquals(code.getCode(),assertThrows(BusinessException.class,f).getErrorCodeInt()); |
|||
} |
|||
String bearer(User u) {return "Bearer "+sessions.createSession(u,clock.instant(), |
|||
com.mydreamjob.audit.AuditContext.builder().requestId("reapplication-test").build()).accessToken();} |
|||
|
|||
@Test void withdrawalCreatesNewIdAndPreservesBothTimelines() { |
|||
var first=apply();service.withdraw(student.getId(),first.getId());em.flush();em.clear(); |
|||
var second=apply();assertNotEquals(first.getId(),second.getId());assertEquals(2,second.getAttemptNumber()); |
|||
assertEquals(first.getId(),second.getPreviousApplicationId());assertEquals("SUBMITTED",second.getStatus()); |
|||
assertEquals("WITHDRAWN",applications.findById(first.getId()).orElseThrow().getStatus()); |
|||
assertEquals(2,history.findByApplicationIdOrderByCreatedAtAsc(first.getId()).size()); |
|||
assertEquals(1,history.findByApplicationIdOrderByCreatedAtAsc(second.getId()).size()); |
|||
assertTrue(matches.findByApplicationId(second.getId()).isEmpty()); |
|||
error(ErrorCode.APPLICATION_DUPLICATE,this::apply); |
|||
} |
|||
@Test void allNonWithdrawnStatesStillBlockReapplication() { |
|||
var a=apply(); |
|||
for(String state:List.of("SUBMITTED","VIEWED","INTERVIEW","REJECTED","HIRED")) { |
|||
a.setStatus(state);applications.saveAndFlush(a);error(ErrorCode.APPLICATION_DUPLICATE,this::apply); |
|||
} |
|||
} |
|||
@Test void closedJobAndOtherStudentCannotBypassRules() { |
|||
var a=apply();var other=user(User.Role.STUDENT); |
|||
error(ErrorCode.APPLICATION_OWNER_MISMATCH,()->service.withdraw(other.getId(),a.getId())); |
|||
service.withdraw(student.getId(),a.getId());job.setStatus("CLOSED");jobs.saveAndFlush(job); |
|||
error(ErrorCode.JOB_NOT_FOUND,this::apply); |
|||
} |
|||
@Test void listsAndHttpExposeNewAttemptWithoutReplacingHistory() throws Exception { |
|||
var a=apply();service.withdraw(student.getId(),a.getId());var b=apply(); |
|||
var view=service.listStudentApplicationViews(student.getId(),null,0,20); |
|||
assertEquals(2,view.getTotalElements());assertTrue(view.stream().anyMatch(x->Integer.valueOf(2).equals(x.get("attemptNumber")))); |
|||
var candidatePage=service.listCompanyCandidates(company.getId(),job.getId(),null,null,null,null,0,20); |
|||
assertEquals(Set.of(1,2),candidatePage.getItems().stream().map(com.mydreamjob.application.dto.CandidateItemView::getAttemptNumber).collect(java.util.stream.Collectors.toSet())); |
|||
mvc.perform(get("/applications/"+b.getId()).header("Authorization",bearer(student))) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.attemptNumber").value(2)) |
|||
.andExpect(jsonPath("$.data.previousApplicationId").value(a.getId())); |
|||
} |
|||
@Test void historicalMatchCannotUnlockNewAttemptOrExistingChatUntilRematched() { |
|||
var a=apply();matches.saveAndFlush(ApplicationMatchRecord.builder().applicationId(a.getId()).jobId(job.getId()) |
|||
.companyId(company.getId()).candidateId(student.getId()).status("MATCHED").candidateInterestScore(90).hrSuitabilityScore(90) |
|||
.companyRequiredCandidateScore(60).studentRequiredHrScore(60).build()); |
|||
var conv=matchConversations.createOrGetMatchConversation(student.getId(),a.getId());long conversation=((Number)conv.get("id")).longValue(); |
|||
service.withdraw(student.getId(),a.getId());var next=apply(); |
|||
error(ErrorCode.APPLICATION_NOT_MATCHED,()->matchConversations.createOrGetMatchConversation(student.getId(),a.getId())); |
|||
error(ErrorCode.APPLICATION_NOT_MATCHED,()->matchConversations.createOrGetMatchConversation(student.getId(),next.getId())); |
|||
var send=new SendMessageRequest();send.setConversationId(conversation);send.setContent("不能沿用旧匹配"); |
|||
error(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,()->messaging.sendMessage(student.getId(),send)); |
|||
var create=new CreateConversationRequest();create.setParticipantUserId(student.getId()); |
|||
error(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,()->messaging.createOrGetDirectConversation(hr.getId(),create)); |
|||
assertNotNull(messaging.getConversation(conversation,student.getId()),"历史仍可阅读"); |
|||
matches.saveAndFlush(ApplicationMatchRecord.builder().applicationId(next.getId()).jobId(job.getId()) |
|||
.companyId(company.getId()).candidateId(student.getId()).status("MATCHED").candidateInterestScore(90).hrSuitabilityScore(90) |
|||
.companyRequiredCandidateScore(60).studentRequiredHrScore(60).build()); |
|||
assertNotNull(messaging.sendMessage(student.getId(),send).get("id")); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue